dspy 0.27.1 → 0.27.3

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.
@@ -24,20 +24,27 @@ module DSPy
24
24
  normalized_messages = format_multimodal_messages(normalized_messages)
25
25
  end
26
26
 
27
- # Set temperature based on model capabilities
28
- temperature = case model
29
- when /^gpt-5/, /^gpt-4o/
30
- 1.0 # GPT-5 and GPT-4o models only support default temperature of 1.0
31
- else
32
- 0.0 # Near-deterministic for other models (0.0 no longer universally supported)
27
+ # Handle O1 model restrictions - convert system messages to user messages
28
+ if o1_model?(model)
29
+ normalized_messages = handle_o1_messages(normalized_messages)
33
30
  end
34
31
 
35
32
  request_params = {
36
33
  model: model,
37
- messages: normalized_messages,
38
- temperature: temperature
34
+ messages: normalized_messages
39
35
  }
40
36
 
37
+ # Add temperature based on model capabilities
38
+ unless o1_model?(model)
39
+ temperature = case model
40
+ when /^gpt-5/, /^gpt-4o/
41
+ 1.0 # GPT-5 and GPT-4o models only support default temperature of 1.0
42
+ else
43
+ 0.0 # Near-deterministic for other models (0.0 no longer universally supported)
44
+ end
45
+ request_params[:temperature] = temperature
46
+ end
47
+
41
48
  # Add response format if provided by strategy
42
49
  if response_format
43
50
  request_params[:response_format] = response_format
@@ -148,6 +155,26 @@ module DSPy
148
155
  end
149
156
  end
150
157
  end
158
+
159
+ # Check if model is an O1 reasoning model (includes O1, O3, O4 series)
160
+ def o1_model?(model_name)
161
+ model_name.match?(/^o[134](-.*)?$/)
162
+ end
163
+
164
+ # Handle O1 model message restrictions
165
+ def handle_o1_messages(messages)
166
+ messages.map do |msg|
167
+ # Convert system messages to user messages for O1 models
168
+ if msg[:role] == 'system'
169
+ {
170
+ role: 'user',
171
+ content: "Instructions: #{msg[:content]}"
172
+ }
173
+ else
174
+ msg
175
+ end
176
+ end
177
+ end
151
178
  end
152
179
  end
153
180
  end
@@ -55,7 +55,7 @@ module DSPy
55
55
 
56
56
  # Let strategy handle the error first
57
57
  if strategy.handle_error(e)
58
- DSPy.logger.info("Strategy #{strategy.name} handled error, will try next strategy")
58
+ DSPy.logger.debug("Strategy #{strategy.name} handled error, trying next strategy")
59
59
  next # Try next strategy
60
60
  end
61
61
 
@@ -64,9 +64,18 @@ module DSPy
64
64
  retry_count += 1
65
65
  backoff_time = calculate_backoff(retry_count)
66
66
 
67
- DSPy.logger.warn(
68
- "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
69
- )
67
+ # Use debug for structured output strategies since they often have expected failures
68
+ log_level = ["openai_structured_output", "gemini_structured_output"].include?(strategy.name) ? :debug : :warn
69
+
70
+ if log_level == :debug
71
+ DSPy.logger.debug(
72
+ "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
73
+ )
74
+ else
75
+ DSPy.logger.warn(
76
+ "Retrying #{strategy.name} after error (attempt #{retry_count}/#{max_retries_for_strategy(strategy)}): #{e.message}"
77
+ )
78
+ end
70
79
 
71
80
  Async::Task.current.sleep(backoff_time) if backoff_time > 0
72
81
  retry
@@ -101,8 +110,8 @@ module DSPy
101
110
  sig { params(strategy: Strategies::BaseStrategy).returns(Integer) }
102
111
  def max_retries_for_strategy(strategy)
103
112
  case strategy.name
104
- when "openai_structured_output"
105
- 1 # Structured outputs rarely benefit from retries
113
+ when "openai_structured_output", "gemini_structured_output"
114
+ 1 # Structured outputs rarely benefit from retries, most errors are permanent
106
115
  when "anthropic_extraction"
107
116
  2 # Anthropic can be a bit more variable
108
117
  else
@@ -31,13 +31,13 @@ module DSPy
31
31
 
32
32
  sig { override.params(messages: T::Array[T::Hash[Symbol, String]], request_params: T::Hash[Symbol, T.untyped]).void }
33
33
  def prepare_request(messages, request_params)
34
- # Convert signature to Gemini schema format
34
+ # Convert signature to Gemini JSON Schema format (supports oneOf/anyOf for unions)
35
35
  schema = DSPy::LM::Adapters::Gemini::SchemaConverter.to_gemini_format(signature_class)
36
36
 
37
- # Add generation_config for structured output
37
+ # Add generation_config for structured output using JSON Schema format
38
38
  request_params[:generation_config] = {
39
39
  response_mime_type: "application/json",
40
- response_schema: schema
40
+ response_json_schema: schema # Use JSON Schema format for proper union support
41
41
  }
42
42
  end
43
43
 
@@ -52,12 +52,25 @@ module DSPy
52
52
  def handle_error(error)
53
53
  # Handle Gemini-specific structured output errors
54
54
  error_msg = error.message.to_s.downcase
55
- if error_msg.include?("schema") || error_msg.include?("generation_config") || error_msg.include?("response_schema")
56
- # Log the error and return true to indicate we handled it
57
- # This allows fallback to another strategy
58
- DSPy.logger.warn("Gemini structured output failed: #{error.message}")
59
- true
55
+
56
+ # Check for permanent errors that shouldn't be retried
57
+ permanent_error_patterns = [
58
+ "schema",
59
+ "generation_config",
60
+ "response_schema",
61
+ "unknown name \"response_mime_type\"",
62
+ "unknown name \"response_schema\"",
63
+ "invalid json payload",
64
+ "no matching sse interaction found", # VCR test configuration issue
65
+ "cannot find field"
66
+ ]
67
+
68
+ if permanent_error_patterns.any? { |pattern| error_msg.include?(pattern) }
69
+ # These are permanent errors - no point retrying
70
+ DSPy.logger.debug("Gemini structured output failed (permanent error, skipping retries): #{error.message}")
71
+ true # Skip retries and try next strategy
60
72
  else
73
+ # Unknown error - let retry logic handle it
61
74
  false
62
75
  end
63
76
  end
data/lib/dspy/lm.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'async'
5
+ require 'securerandom'
5
6
 
6
7
  # Load adapter infrastructure
7
8
  require_relative 'lm/errors'
@@ -42,7 +43,17 @@ module DSPy
42
43
  end
43
44
 
44
45
  def chat(inference_module, input_values, &block)
46
+ # Capture the current DSPy context before entering Sync block
47
+ parent_context = DSPy::Context.current.dup
48
+
45
49
  Sync do
50
+ # Properly restore the context in the new fiber created by Sync
51
+ # We need to set both thread and fiber storage for the new context system
52
+ thread_key = :"dspy_context_#{Thread.current.object_id}"
53
+ Thread.current[thread_key] = parent_context
54
+ Thread.current[:dspy_context] = parent_context # Keep for backward compatibility
55
+ Fiber[:dspy_context] = parent_context
56
+
46
57
  signature_class = inference_module.signature_class
47
58
 
48
59
  # Build messages from inference module
@@ -225,7 +236,7 @@ module DSPy
225
236
  # Wrap LLM call in span tracking
226
237
  response = DSPy::Context.with_span(
227
238
  operation: 'llm.generate',
228
- 'langfuse.observation.type' => 'generation',
239
+ **DSPy::ObservationType::Generation.langfuse_attributes,
229
240
  'langfuse.observation.input' => input_json,
230
241
  'gen_ai.system' => provider,
231
242
  'gen_ai.request.model' => model,
@@ -267,11 +278,26 @@ module DSPy
267
278
  token_usage = extract_token_usage(response)
268
279
 
269
280
  if token_usage.any?
270
- DSPy.log('lm.tokens', **token_usage.merge({
281
+ event_attributes = token_usage.merge({
271
282
  'gen_ai.system' => provider,
272
283
  'gen_ai.request.model' => model,
273
284
  'dspy.signature' => signature_class_name
274
- }))
285
+ })
286
+
287
+ # Add timing and request correlation if available
288
+ request_id = Thread.current[:dspy_request_id]
289
+ start_time = Thread.current[:dspy_request_start_time]
290
+
291
+ if request_id
292
+ event_attributes['request_id'] = request_id
293
+ end
294
+
295
+ if start_time
296
+ duration = Time.now - start_time
297
+ event_attributes['duration'] = duration
298
+ end
299
+
300
+ DSPy.event('lm.tokens', event_attributes)
275
301
  end
276
302
 
277
303
  token_usage
@@ -341,15 +367,32 @@ module DSPy
341
367
  end
342
368
 
343
369
  def execute_raw_chat(messages, &streaming_block)
344
- response = instrument_lm_request(messages, 'RawPrompt') do
345
- # Convert messages to hash format for adapter
346
- hash_messages = messages_to_hash_array(messages)
347
- # Direct adapter call, no strategies or JSON parsing
348
- adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
349
- end
370
+ # Generate unique request ID for tracking
371
+ request_id = SecureRandom.hex(8)
372
+ start_time = Time.now
373
+
374
+ # Store request context for correlation
375
+ Thread.current[:dspy_request_id] = request_id
376
+ Thread.current[:dspy_request_start_time] = start_time
350
377
 
351
- # Return raw response content, not parsed JSON
352
- response.content
378
+ begin
379
+ response = instrument_lm_request(messages, 'RawPrompt') do
380
+ # Convert messages to hash format for adapter
381
+ hash_messages = messages_to_hash_array(messages)
382
+ # Direct adapter call, no strategies or JSON parsing
383
+ adapter.chat(messages: hash_messages, signature: nil, &streaming_block)
384
+ end
385
+
386
+ # Emit the standard lm.tokens event (consistent with other LM calls)
387
+ emit_token_usage(response, 'RawPrompt')
388
+
389
+ # Return raw response content, not parsed JSON
390
+ response.content
391
+ ensure
392
+ # Clean up thread-local storage
393
+ Thread.current[:dspy_request_id] = nil
394
+ Thread.current[:dspy_request_start_time] = nil
395
+ end
353
396
  end
354
397
 
355
398
  # Convert messages to normalized Message objects
@@ -36,17 +36,33 @@ module DSPy
36
36
 
37
37
  sig { override.params(text: String).returns(T::Array[Float]) }
38
38
  def embed(text)
39
- ensure_ready!
40
-
41
- # Preprocess text
42
- cleaned_text = preprocess_text(text)
43
-
44
- # Generate embedding
45
- result = @model.call(cleaned_text)
46
-
47
- # Extract embedding array and normalize
48
- embedding = result.first.to_a
49
- normalize_vector(embedding)
39
+ DSPy::Context.with_span(
40
+ operation: 'embedding.generate',
41
+ **DSPy::ObservationType::Embedding.langfuse_attributes,
42
+ 'embedding.model' => @model_name,
43
+ 'embedding.input' => text[0..200], # Truncate for logging
44
+ 'embedding.input_length' => text.length
45
+ ) do |span|
46
+ ensure_ready!
47
+
48
+ # Preprocess text
49
+ cleaned_text = preprocess_text(text)
50
+
51
+ # Generate embedding
52
+ result = @model.call(cleaned_text)
53
+
54
+ # Extract embedding array and normalize
55
+ embedding = result.first.to_a
56
+ normalized = normalize_vector(embedding)
57
+
58
+ # Add embedding metadata to span
59
+ if span
60
+ span.set_attribute('embedding.dimension', normalized.length)
61
+ span.set_attribute('embedding.magnitude', Math.sqrt(normalized.sum { |x| x * x }))
62
+ end
63
+
64
+ normalized
65
+ end
50
66
  end
51
67
 
52
68
  sig { override.params(texts: T::Array[String]).returns(T::Array[T::Array[Float]]) }
@@ -95,15 +95,32 @@ module DSPy
95
95
  # Semantic search using embeddings
96
96
  sig { params(query: String, user_id: T.nilable(String), limit: T.nilable(Integer), threshold: T.nilable(Float)).returns(T::Array[MemoryRecord]) }
97
97
  def search_memories(query, user_id: nil, limit: 10, threshold: 0.5)
98
- # Generate embedding for the query
99
- query_embedding = @embedding_engine.embed(query)
100
-
101
- # Perform vector search if supported
102
- if @store.supports_vector_search?
103
- @store.vector_search(query_embedding, user_id: user_id, limit: limit, threshold: threshold)
104
- else
105
- # Fallback to text search
106
- @store.search(query, user_id: user_id, limit: limit)
98
+ DSPy::Context.with_span(
99
+ operation: 'memory.search',
100
+ **DSPy::ObservationType::Retriever.langfuse_attributes,
101
+ 'retriever.query' => query,
102
+ 'retriever.user_id' => user_id,
103
+ 'retriever.limit' => limit,
104
+ 'retriever.threshold' => threshold
105
+ ) do |span|
106
+ # Generate embedding for the query
107
+ query_embedding = @embedding_engine.embed(query)
108
+
109
+ # Perform vector search if supported
110
+ results = if @store.supports_vector_search?
111
+ @store.vector_search(query_embedding, user_id: user_id, limit: limit, threshold: threshold)
112
+ else
113
+ # Fallback to text search
114
+ @store.search(query, user_id: user_id, limit: limit)
115
+ end
116
+
117
+ # Add retrieval results to span
118
+ if span
119
+ span.set_attribute('retriever.results_count', results.length)
120
+ span.set_attribute('retriever.results', results.map { |r| { id: r.id, content: r.content[0..100] } }.to_json)
121
+ end
122
+
123
+ results
107
124
  end
108
125
  end
109
126
 
@@ -34,12 +34,20 @@ module DSPy
34
34
  coerce_union_value(value, prop_type)
35
35
  when ->(type) { array_type?(type) }
36
36
  coerce_array_value(value, prop_type)
37
+ when ->(type) { hash_type?(type) }
38
+ coerce_hash_value(value, prop_type)
37
39
  when ->(type) { enum_type?(type) }
38
- extract_enum_class(prop_type).deserialize(value)
39
- when Float, ->(type) { simple_type_match?(type, Float) }
40
+ coerce_enum_value(value, prop_type)
41
+ when ->(type) { type == Float || simple_type_match?(type, Float) }
40
42
  value.to_f
41
- when Integer, ->(type) { simple_type_match?(type, Integer) }
43
+ when ->(type) { type == Integer || simple_type_match?(type, Integer) }
42
44
  value.to_i
45
+ when ->(type) { type == Date || simple_type_match?(type, Date) }
46
+ coerce_date_value(value)
47
+ when ->(type) { type == DateTime || simple_type_match?(type, DateTime) }
48
+ coerce_datetime_value(value)
49
+ when ->(type) { type == Time || simple_type_match?(type, Time) }
50
+ coerce_time_value(value)
43
51
  when ->(type) { struct_type?(type) }
44
52
  coerce_struct_value(value, prop_type)
45
53
  else
@@ -88,6 +96,12 @@ module DSPy
88
96
  true
89
97
  end
90
98
 
99
+ # Checks if a type is a hash type
100
+ sig { params(type: T.untyped).returns(T::Boolean) }
101
+ def hash_type?(type)
102
+ type.is_a?(T::Types::TypedHash)
103
+ end
104
+
91
105
  # Checks if a type is a struct type
92
106
  sig { params(type: T.untyped).returns(T::Boolean) }
93
107
  def struct_type?(type)
@@ -124,6 +138,28 @@ module DSPy
124
138
  value.map { |element| coerce_value_to_type(element, element_type) }
125
139
  end
126
140
 
141
+ # Coerces a hash value, converting keys and values as needed
142
+ sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
143
+ def coerce_hash_value(value, prop_type)
144
+ return value unless value.is_a?(Hash)
145
+ return value unless prop_type.is_a?(T::Types::TypedHash)
146
+
147
+ key_type = prop_type.keys
148
+ value_type = prop_type.values
149
+
150
+ # Convert string keys to enum instances if key_type is an enum
151
+ result = if enum_type?(key_type)
152
+ enum_class = extract_enum_class(key_type)
153
+ value.transform_keys { |k| enum_class.deserialize(k.to_s) }
154
+ else
155
+ # For non-enum keys, coerce them to the expected type
156
+ value.transform_keys { |k| coerce_value_to_type(k, key_type) }
157
+ end
158
+
159
+ # Coerce values to their expected types
160
+ result.transform_values { |v| coerce_value_to_type(v, value_type) }
161
+ end
162
+
127
163
  # Coerces a struct value from a hash
128
164
  sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
129
165
  def coerce_struct_value(value, prop_type)
@@ -214,6 +250,63 @@ module DSPy
214
250
  DSPy.logger.debug("Failed to coerce union type: #{e.message}")
215
251
  value
216
252
  end
253
+
254
+ # Coerces a date value from string using ISO 8601 format
255
+ sig { params(value: T.untyped).returns(T.nilable(Date)) }
256
+ def coerce_date_value(value)
257
+ return value if value.is_a?(Date)
258
+ return nil if value.nil? || value.to_s.strip.empty?
259
+
260
+ # Support ISO 8601 format (YYYY-MM-DD) like ActiveRecord
261
+ Date.parse(value.to_s)
262
+ rescue ArgumentError, TypeError
263
+ # Return nil for invalid dates rather than crashing
264
+ DSPy.logger.debug("Failed to coerce to Date: #{value}")
265
+ nil
266
+ end
267
+
268
+ # Coerces a datetime value from string using ISO 8601 format with timezone
269
+ sig { params(value: T.untyped).returns(T.nilable(DateTime)) }
270
+ def coerce_datetime_value(value)
271
+ return value if value.is_a?(DateTime)
272
+ return nil if value.nil? || value.to_s.strip.empty?
273
+
274
+ # Parse ISO 8601 with timezone like ActiveRecord
275
+ # Formats: 2024-01-15T10:30:45Z, 2024-01-15T10:30:45+00:00, 2024-01-15 10:30:45
276
+ DateTime.parse(value.to_s)
277
+ rescue ArgumentError, TypeError
278
+ DSPy.logger.debug("Failed to coerce to DateTime: #{value}")
279
+ nil
280
+ end
281
+
282
+ # Coerces a time value from string, converting to UTC like ActiveRecord
283
+ sig { params(value: T.untyped).returns(T.nilable(Time)) }
284
+ def coerce_time_value(value)
285
+ return value if value.is_a?(Time)
286
+ return nil if value.nil? || value.to_s.strip.empty?
287
+
288
+ # Parse and convert to UTC (like ActiveRecord with time_zone_aware_attributes)
289
+ # This ensures consistent timezone handling across the system
290
+ Time.parse(value.to_s).utc
291
+ rescue ArgumentError, TypeError
292
+ DSPy.logger.debug("Failed to coerce to Time: #{value}")
293
+ nil
294
+ end
295
+
296
+ # Coerces a value to an enum, handling both strings and existing enum instances
297
+ sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
298
+ def coerce_enum_value(value, prop_type)
299
+ enum_class = extract_enum_class(prop_type)
300
+
301
+ # If value is already an instance of the enum class, return it as-is
302
+ return value if value.is_a?(enum_class)
303
+
304
+ # Otherwise, try to deserialize from string
305
+ enum_class.deserialize(value.to_s)
306
+ rescue ArgumentError, KeyError => e
307
+ DSPy.logger.debug("Failed to coerce to enum #{enum_class}: #{e.message}")
308
+ value
309
+ end
217
310
  end
218
311
  end
219
312
  end
data/lib/dspy/module.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'dry-configurable'
5
+ require_relative 'context'
5
6
 
6
7
  module DSPy
7
8
  class Module
@@ -21,8 +22,25 @@ module DSPy
21
22
  .returns(T.type_parameter(:O))
22
23
  end
23
24
  def forward(**input_values)
24
- # Cast the result of forward_untyped to the expected output type
25
- T.cast(forward_untyped(**input_values), T.type_parameter(:O))
25
+ # Create span for this module's execution
26
+ observation_type = DSPy::ObservationType.for_module_class(self.class)
27
+ DSPy::Context.with_span(
28
+ operation: "#{self.class.name}.forward",
29
+ **observation_type.langfuse_attributes,
30
+ 'langfuse.observation.input' => input_values.to_json,
31
+ 'dspy.module' => self.class.name
32
+ ) do |span|
33
+ result = forward_untyped(**input_values)
34
+
35
+ # Add output to span
36
+ if span && result
37
+ output_json = result.respond_to?(:to_h) ? result.to_h.to_json : result.to_json rescue result.to_s
38
+ span.set_attribute('langfuse.observation.output', output_json)
39
+ end
40
+
41
+ # Cast the result of forward_untyped to the expected output type
42
+ T.cast(result, T.type_parameter(:O))
43
+ end
26
44
  end
27
45
 
28
46
  # The implementation method that subclasses must override
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'sorbet-runtime'
4
+
5
+ module DSPy
6
+ # Langfuse observation types as a T::Enum for type safety
7
+ # Maps to the official Langfuse observation types: https://langfuse.com/docs/observability/features/observation-types
8
+ class ObservationType < T::Enum
9
+ enums do
10
+ # LLM generation calls - used for direct model inference
11
+ Generation = new('generation')
12
+
13
+ # Agent operations - decision-making processes using tools/LLM guidance
14
+ Agent = new('agent')
15
+
16
+ # External tool calls (APIs, functions, etc.)
17
+ Tool = new('tool')
18
+
19
+ # Chains linking different application steps/components
20
+ Chain = new('chain')
21
+
22
+ # Data retrieval operations (vector stores, databases, memory search)
23
+ Retriever = new('retriever')
24
+
25
+ # Embedding generation calls
26
+ Embedding = new('embedding')
27
+
28
+ # Functions that assess quality/relevance of outputs
29
+ Evaluator = new('evaluator')
30
+
31
+ # Generic spans for durations of work units
32
+ Span = new('span')
33
+
34
+ # Discrete events/moments in time
35
+ Event = new('event')
36
+ end
37
+
38
+ # Get the appropriate observation type for a DSPy module class
39
+ sig { params(module_class: T.class_of(DSPy::Module)).returns(ObservationType) }
40
+ def self.for_module_class(module_class)
41
+ case module_class.name
42
+ when /ReAct/, /CodeAct/
43
+ Agent
44
+ when /ChainOfThought/
45
+ Chain
46
+ when /Evaluator/
47
+ Evaluator
48
+ else
49
+ Span
50
+ end
51
+ end
52
+
53
+ # Returns the langfuse attribute key and value as an array
54
+ sig { returns([String, String]) }
55
+ def langfuse_attribute
56
+ ['langfuse.observation.type', serialize]
57
+ end
58
+
59
+ # Returns a hash with the langfuse attribute for easy merging
60
+ sig { returns(T::Hash[String, String]) }
61
+ def langfuse_attributes
62
+ { 'langfuse.observation.type' => serialize }
63
+ end
64
+ end
65
+ end
@@ -15,6 +15,13 @@ module DSPy
15
15
  public_key = ENV['LANGFUSE_PUBLIC_KEY']
16
16
  secret_key = ENV['LANGFUSE_SECRET_KEY']
17
17
 
18
+ # Skip OTLP configuration in test environment UNLESS Langfuse credentials are explicitly provided
19
+ # This allows observability tests to run while protecting general tests from network calls
20
+ if (ENV['RACK_ENV'] == 'test' || ENV['RAILS_ENV'] == 'test' || defined?(RSpec)) && !(public_key && secret_key)
21
+ DSPy.log('observability.disabled', reason: 'Test environment detected - OTLP disabled')
22
+ return
23
+ end
24
+
18
25
  unless public_key && secret_key
19
26
  return
20
27
  end
data/lib/dspy/predict.rb CHANGED
@@ -131,51 +131,41 @@ module DSPy
131
131
  with_prompt(@prompt.add_examples(examples))
132
132
  end
133
133
 
134
- sig { override.params(kwargs: T.untyped).returns(T.type_parameter(:O)) }
135
- def forward(**kwargs)
136
- @last_input_values = kwargs.clone
137
- T.cast(forward_untyped(**kwargs), T.type_parameter(:O))
138
- end
134
+ # Remove forward override to let Module#forward handle span creation
139
135
 
140
136
  sig { params(input_values: T.untyped).returns(T.untyped) }
141
137
  def forward_untyped(**input_values)
142
- # Wrap prediction in span tracking
143
- DSPy::Context.with_span(
144
- operation: "#{self.class.name}.forward",
145
- 'langfuse.observation.type' => 'span',
146
- 'langfuse.observation.input' => input_values.to_json,
147
- 'dspy.module' => self.class.name,
148
- 'dspy.signature' => @signature_class.name
149
- ) do |span|
150
- # Validate input
151
- validate_input_struct(input_values)
152
-
153
- # Check if LM is configured
154
- current_lm = lm
155
- if current_lm.nil?
156
- raise DSPy::ConfigurationError.missing_lm(self.class.name)
157
- end
158
-
159
- # Call LM and process response
160
- output_attributes = current_lm.chat(self, input_values)
161
- processed_output = process_lm_output(output_attributes)
162
-
163
- # Create combined result struct
164
- prediction_result = create_prediction_result(input_values, processed_output)
165
-
166
- # Add output to span
167
- if span && prediction_result
168
- output_hash = prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
169
- span.set_attribute('langfuse.observation.output', DSPy::Utils::Serialization.to_json(output_hash))
170
- end
171
-
172
- prediction_result
138
+ # Module#forward handles span creation, we just do the prediction logic
139
+
140
+ # Apply type coercion to input values first
141
+ input_props = @signature_class.input_struct_class.props
142
+ coerced_input_values = coerce_output_attributes(input_values, input_props)
143
+
144
+ # Store coerced input values for optimization
145
+ @last_input_values = coerced_input_values.clone
146
+
147
+ # Validate input with coerced values
148
+ validate_input_struct(coerced_input_values)
149
+
150
+ # Check if LM is configured
151
+ current_lm = lm
152
+ if current_lm.nil?
153
+ raise DSPy::ConfigurationError.missing_lm(self.class.name)
173
154
  end
155
+
156
+ # Call LM and process response with coerced input values
157
+ output_attributes = current_lm.chat(self, coerced_input_values)
158
+ processed_output = process_lm_output(output_attributes)
159
+
160
+ # Create combined result struct with coerced input values
161
+ prediction_result = create_prediction_result(coerced_input_values, processed_output)
162
+
163
+ prediction_result
174
164
  end
175
165
 
176
166
  private
177
167
 
178
- # Validates input using signature struct
168
+ # Validates input using signature struct (assumes input is already coerced)
179
169
  sig { params(input_values: T::Hash[Symbol, T.untyped]).void }
180
170
  def validate_input_struct(input_values)
181
171
  @signature_class.input_struct_class.new(**input_values)