dspy 0.24.0 → 0.24.2
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 +1 -0
- data/lib/dspy/chain_of_thought.rb +37 -8
- data/lib/dspy/context.rb +36 -15
- data/lib/dspy/lm/adapters/openai_adapter.rb +18 -10
- data/lib/dspy/lm.rb +30 -17
- data/lib/dspy/observability.rb +14 -1
- data/lib/dspy/predict.rb +12 -2
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +13 -0
- metadata +3 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: a4383ac0dbb6237559bb521cf0d1355f9333f6495895742f6b2c4360d7c68392
|
4
|
+
data.tar.gz: 914f295500dd86cf4d2fe611450ddf3f8f6a01b1c7834cb5b8a9057d7449c8b4
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 3d48a54390b11eee3a38d15770abda175cd7545aa30b24d48123e2ab6657e7f3212bbc4aa3bddab5a6ec700d3137b1e7bbd8b7f4d5e78313f7d3d90b574b47a3
|
7
|
+
data.tar.gz: 6189b3137e9fe1a36e6bedfc45a1a9ba10308089d497885edbfd8a20ba7879824c4e103b96eaa1efaca93e8e6c90aa4154f99ba8aa278528045d96aec7ff2024
|
data/README.md
CHANGED
@@ -191,6 +191,7 @@ DSPy.rb has rapidly evolved from experimental to production-ready:
|
|
191
191
|
- ✅ **Optimization Framework** - MIPROv2 algorithm with storage & persistence
|
192
192
|
|
193
193
|
### Recent Advances
|
194
|
+
- ✅ **Enhanced Langfuse Integration (v0.24.1)** - Comprehensive OpenTelemetry span reporting with proper input/output, hierarchical nesting, accurate timing, and observation types
|
194
195
|
- ✅ **Comprehensive Multimodal Framework** - Complete image analysis with `DSPy::Image`, type-safe bounding boxes, vision model integration
|
195
196
|
- ✅ **Advanced Type System** - `T::Enum` integration, union types for agentic workflows, complex type coercion
|
196
197
|
- ✅ **Production-Ready Evaluation** - Multi-factor metrics beyond accuracy, error-resilient evaluation pipelines
|
@@ -82,16 +82,45 @@ module DSPy
|
|
82
82
|
sig { returns(T.class_of(DSPy::Signature)) }
|
83
83
|
attr_reader :original_signature
|
84
84
|
|
85
|
-
# Override forward_untyped to add ChainOfThought-specific analysis
|
85
|
+
# Override forward_untyped to add ChainOfThought-specific analysis and tracing
|
86
86
|
sig { override.params(input_values: T.untyped).returns(T.untyped) }
|
87
87
|
def forward_untyped(**input_values)
|
88
|
-
#
|
89
|
-
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
|
88
|
+
# Wrap in chain-specific span tracking (overrides parent's span attributes)
|
89
|
+
DSPy::Context.with_span(
|
90
|
+
operation: "#{self.class.name}.forward",
|
91
|
+
'langfuse.observation.type' => 'chain',
|
92
|
+
'langfuse.observation.input' => input_values.to_json,
|
93
|
+
'dspy.module' => 'ChainOfThought',
|
94
|
+
'dspy.signature' => @original_signature.name
|
95
|
+
) do |span|
|
96
|
+
# Call parent prediction logic (which will create its own nested span)
|
97
|
+
prediction_result = super(**input_values)
|
98
|
+
|
99
|
+
# Enhance span with reasoning data
|
100
|
+
if span && prediction_result
|
101
|
+
# Include reasoning in output for chain observation
|
102
|
+
output_with_reasoning = if prediction_result.respond_to?(:reasoning) && prediction_result.reasoning
|
103
|
+
output_hash = prediction_result.respond_to?(:to_h) ? prediction_result.to_h : {}
|
104
|
+
output_hash.merge(reasoning: prediction_result.reasoning)
|
105
|
+
else
|
106
|
+
prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
|
107
|
+
end
|
108
|
+
|
109
|
+
span.set_attribute('langfuse.observation.output', output_with_reasoning.to_json)
|
110
|
+
|
111
|
+
# Add reasoning metrics
|
112
|
+
if prediction_result.respond_to?(:reasoning) && prediction_result.reasoning
|
113
|
+
span.set_attribute('cot.reasoning_length', prediction_result.reasoning.length)
|
114
|
+
span.set_attribute('cot.has_reasoning', true)
|
115
|
+
span.set_attribute('cot.reasoning_steps', count_reasoning_steps(prediction_result.reasoning))
|
116
|
+
end
|
117
|
+
end
|
118
|
+
|
119
|
+
# Analyze reasoning (emits events for backwards compatibility)
|
120
|
+
analyze_reasoning(prediction_result)
|
121
|
+
|
122
|
+
prediction_result
|
123
|
+
end
|
95
124
|
end
|
96
125
|
|
97
126
|
private
|
data/lib/dspy/context.rb
CHANGED
@@ -26,37 +26,58 @@ module DSPy
|
|
26
26
|
**attributes
|
27
27
|
}
|
28
28
|
|
29
|
-
# Log span start with proper hierarchy
|
29
|
+
# Log span start with proper hierarchy (internal logging only)
|
30
30
|
DSPy.log('span.start', **span_attributes)
|
31
31
|
|
32
|
-
#
|
33
|
-
otel_span = nil
|
34
|
-
if DSPy::Observability.enabled?
|
35
|
-
otel_span = DSPy::Observability.start_span(operation, span_attributes)
|
36
|
-
end
|
37
|
-
|
38
|
-
# Push to stack for child spans
|
32
|
+
# Push to stack for child spans tracking
|
39
33
|
current[:span_stack].push(span_id)
|
40
34
|
|
41
35
|
begin
|
42
|
-
|
36
|
+
# Use OpenTelemetry's proper context management for nesting
|
37
|
+
if DSPy::Observability.enabled? && DSPy::Observability.tracer
|
38
|
+
# Prepare attributes and add trace name for root spans
|
39
|
+
span_attributes = attributes.transform_keys(&:to_s).reject { |k, v| v.nil? }
|
40
|
+
|
41
|
+
# Set trace name if this is likely a root span (no parent in our stack)
|
42
|
+
if current[:span_stack].length == 1 # This will be the first span
|
43
|
+
span_attributes['langfuse.trace.name'] = operation
|
44
|
+
end
|
45
|
+
|
46
|
+
# Record start time for explicit duration tracking
|
47
|
+
otel_start_time = Time.now
|
48
|
+
|
49
|
+
DSPy::Observability.tracer.in_span(
|
50
|
+
operation,
|
51
|
+
attributes: span_attributes,
|
52
|
+
kind: :internal
|
53
|
+
) do |span|
|
54
|
+
result = yield(span)
|
55
|
+
|
56
|
+
# Add explicit timing information to help Langfuse
|
57
|
+
if span
|
58
|
+
duration_ms = ((Time.now - otel_start_time) * 1000).round(3)
|
59
|
+
span.set_attribute('duration.ms', duration_ms)
|
60
|
+
span.set_attribute('langfuse.observation.startTime', otel_start_time.iso8601(3))
|
61
|
+
span.set_attribute('langfuse.observation.endTime', Time.now.iso8601(3))
|
62
|
+
end
|
63
|
+
|
64
|
+
result
|
65
|
+
end
|
66
|
+
else
|
67
|
+
yield(nil)
|
68
|
+
end
|
43
69
|
ensure
|
44
70
|
# Pop from stack
|
45
71
|
current[:span_stack].pop
|
46
72
|
|
47
|
-
# Log span end with duration
|
73
|
+
# Log span end with duration (internal logging only)
|
48
74
|
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
49
75
|
DSPy.log('span.end',
|
50
76
|
trace_id: current[:trace_id],
|
51
77
|
span_id: span_id,
|
52
78
|
duration_ms: duration_ms
|
53
79
|
)
|
54
|
-
|
55
|
-
# Finish OpenTelemetry span
|
56
|
-
DSPy::Observability.finish_span(otel_span) if otel_span
|
57
80
|
end
|
58
|
-
|
59
|
-
result
|
60
81
|
end
|
61
82
|
|
62
83
|
def clear!
|
@@ -16,18 +16,26 @@ module DSPy
|
|
16
16
|
|
17
17
|
def chat(messages:, signature: nil, response_format: nil, &block)
|
18
18
|
normalized_messages = normalize_messages(messages)
|
19
|
-
|
19
|
+
|
20
20
|
# Validate vision support if images are present
|
21
21
|
if contains_images?(normalized_messages)
|
22
22
|
VisionModels.validate_vision_support!('openai', model)
|
23
23
|
# Convert messages to OpenAI format with proper image handling
|
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)
|
33
|
+
end
|
34
|
+
|
27
35
|
request_params = {
|
28
36
|
model: model,
|
29
37
|
messages: normalized_messages,
|
30
|
-
temperature:
|
38
|
+
temperature: temperature
|
31
39
|
}
|
32
40
|
|
33
41
|
# Add response format if provided by strategy
|
@@ -48,7 +56,7 @@ module DSPy
|
|
48
56
|
|
49
57
|
begin
|
50
58
|
response = @client.chat.completions.create(**request_params)
|
51
|
-
|
59
|
+
|
52
60
|
if response.respond_to?(:error) && response.error
|
53
61
|
raise AdapterError, "OpenAI API error: #{response.error}"
|
54
62
|
end
|
@@ -65,7 +73,7 @@ module DSPy
|
|
65
73
|
|
66
74
|
# Convert usage data to typed struct
|
67
75
|
usage_struct = UsageFactory.create('openai', usage)
|
68
|
-
|
76
|
+
|
69
77
|
# Create typed metadata
|
70
78
|
metadata = ResponseMetadataFactory.create('openai', {
|
71
79
|
model: model,
|
@@ -75,7 +83,7 @@ module DSPy
|
|
75
83
|
system_fingerprint: response.system_fingerprint,
|
76
84
|
finish_reason: choice.finish_reason
|
77
85
|
})
|
78
|
-
|
86
|
+
|
79
87
|
Response.new(
|
80
88
|
content: content,
|
81
89
|
usage: usage_struct,
|
@@ -84,14 +92,14 @@ module DSPy
|
|
84
92
|
rescue => e
|
85
93
|
# Check for specific error types and messages
|
86
94
|
error_msg = e.message.to_s
|
87
|
-
|
95
|
+
|
88
96
|
# Try to parse error body if it looks like JSON
|
89
97
|
error_body = if error_msg.start_with?('{')
|
90
98
|
JSON.parse(error_msg) rescue nil
|
91
99
|
elsif e.respond_to?(:response) && e.response
|
92
100
|
e.response[:body] rescue nil
|
93
101
|
end
|
94
|
-
|
102
|
+
|
95
103
|
# Check for specific image-related errors
|
96
104
|
if error_msg.include?('image_parse_error') || error_msg.include?('unsupported image')
|
97
105
|
raise AdapterError, "Image processing failed: #{error_msg}. Ensure your image is a valid PNG, JPEG, GIF, or WebP format and under 5MB."
|
@@ -113,7 +121,7 @@ module DSPy
|
|
113
121
|
def supports_structured_outputs?
|
114
122
|
DSPy::LM::Adapters::OpenAI::SchemaConverter.supports_structured_outputs?(model)
|
115
123
|
end
|
116
|
-
|
124
|
+
|
117
125
|
def format_multimodal_messages(messages)
|
118
126
|
messages.map do |msg|
|
119
127
|
if msg[:content].is_a?(Array)
|
@@ -130,7 +138,7 @@ module DSPy
|
|
130
138
|
item
|
131
139
|
end
|
132
140
|
end
|
133
|
-
|
141
|
+
|
134
142
|
{
|
135
143
|
role: msg[:role],
|
136
144
|
content: formatted_content
|
data/lib/dspy/lm.rb
CHANGED
@@ -209,35 +209,48 @@ module DSPy
|
|
209
209
|
|
210
210
|
# Common instrumentation method for LM requests
|
211
211
|
def instrument_lm_request(messages, signature_class_name, &execution_block)
|
212
|
-
#
|
213
|
-
|
212
|
+
# Prepare input for tracing - convert messages to JSON for input tracking
|
213
|
+
input_messages = messages.map do |m|
|
214
214
|
if m.is_a?(Message)
|
215
|
-
m.content
|
215
|
+
{ role: m.role, content: m.content }
|
216
216
|
else
|
217
|
-
m
|
217
|
+
m
|
218
218
|
end
|
219
|
-
end
|
220
|
-
|
219
|
+
end
|
220
|
+
input_json = input_messages.to_json
|
221
221
|
|
222
222
|
# Wrap LLM call in span tracking
|
223
223
|
response = DSPy::Context.with_span(
|
224
224
|
operation: 'llm.generate',
|
225
|
+
'langfuse.observation.type' => 'generation',
|
226
|
+
'langfuse.observation.input' => input_json,
|
225
227
|
'gen_ai.system' => provider,
|
226
228
|
'gen_ai.request.model' => model,
|
229
|
+
'gen_ai.prompt' => input_json,
|
227
230
|
'dspy.signature' => signature_class_name
|
228
|
-
) do
|
231
|
+
) do |span|
|
229
232
|
result = execution_block.call
|
230
233
|
|
231
|
-
# Add usage data
|
232
|
-
if
|
233
|
-
|
234
|
-
|
235
|
-
|
236
|
-
'gen_ai.
|
237
|
-
|
238
|
-
|
239
|
-
|
240
|
-
)
|
234
|
+
# Add output and usage data directly to span
|
235
|
+
if span && result
|
236
|
+
# Add completion output
|
237
|
+
if result.content
|
238
|
+
span.set_attribute('langfuse.observation.output', result.content)
|
239
|
+
span.set_attribute('gen_ai.completion', result.content)
|
240
|
+
end
|
241
|
+
|
242
|
+
# Add response model if available
|
243
|
+
if result.respond_to?(:metadata) && result.metadata&.model
|
244
|
+
span.set_attribute('gen_ai.response.model', result.metadata.model)
|
245
|
+
end
|
246
|
+
|
247
|
+
# Add token usage
|
248
|
+
if result.respond_to?(:usage) && result.usage
|
249
|
+
usage = result.usage
|
250
|
+
span.set_attribute('gen_ai.usage.prompt_tokens', usage.input_tokens) if usage.input_tokens
|
251
|
+
span.set_attribute('gen_ai.usage.completion_tokens', usage.output_tokens) if usage.output_tokens
|
252
|
+
span.set_attribute('gen_ai.usage.total_tokens', usage.total_tokens) if usage.total_tokens
|
253
|
+
end
|
241
254
|
end
|
242
255
|
|
243
256
|
result
|
data/lib/dspy/observability.rb
CHANGED
@@ -20,7 +20,7 @@ module DSPy
|
|
20
20
|
|
21
21
|
# Determine endpoint based on host
|
22
22
|
host = ENV['LANGFUSE_HOST'] || 'https://cloud.langfuse.com'
|
23
|
-
@endpoint = "#{host}/api/public/otel"
|
23
|
+
@endpoint = "#{host}/api/public/otel/v1/traces"
|
24
24
|
|
25
25
|
begin
|
26
26
|
# Load OpenTelemetry gems
|
@@ -73,6 +73,10 @@ module DSPy
|
|
73
73
|
@enabled == true
|
74
74
|
end
|
75
75
|
|
76
|
+
def tracer
|
77
|
+
@tracer
|
78
|
+
end
|
79
|
+
|
76
80
|
def start_span(operation_name, attributes = {})
|
77
81
|
return nil unless enabled? && tracer
|
78
82
|
|
@@ -99,6 +103,15 @@ module DSPy
|
|
99
103
|
DSPy.log('observability.span_finish_error', error: e.message)
|
100
104
|
end
|
101
105
|
|
106
|
+
def flush!
|
107
|
+
return unless enabled?
|
108
|
+
|
109
|
+
# Force flush any pending spans
|
110
|
+
OpenTelemetry.tracer_provider.force_flush
|
111
|
+
rescue StandardError => e
|
112
|
+
DSPy.log('observability.flush_error', error: e.message)
|
113
|
+
end
|
114
|
+
|
102
115
|
def reset!
|
103
116
|
@enabled = false
|
104
117
|
@tracer = nil
|
data/lib/dspy/predict.rb
CHANGED
@@ -141,9 +141,11 @@ module DSPy
|
|
141
141
|
# Wrap prediction in span tracking
|
142
142
|
DSPy::Context.with_span(
|
143
143
|
operation: "#{self.class.name}.forward",
|
144
|
+
'langfuse.observation.type' => 'span',
|
145
|
+
'langfuse.observation.input' => input_values.to_json,
|
144
146
|
'dspy.module' => self.class.name,
|
145
147
|
'dspy.signature' => @signature_class.name
|
146
|
-
) do
|
148
|
+
) do |span|
|
147
149
|
# Validate input
|
148
150
|
validate_input_struct(input_values)
|
149
151
|
|
@@ -158,7 +160,15 @@ module DSPy
|
|
158
160
|
processed_output = process_lm_output(output_attributes)
|
159
161
|
|
160
162
|
# Create combined result struct
|
161
|
-
create_prediction_result(input_values, processed_output)
|
163
|
+
prediction_result = create_prediction_result(input_values, processed_output)
|
164
|
+
|
165
|
+
# Add output to span
|
166
|
+
if span && prediction_result
|
167
|
+
output_hash = prediction_result.respond_to?(:to_h) ? prediction_result.to_h : prediction_result.to_s
|
168
|
+
span.set_attribute('langfuse.observation.output', output_hash.to_json)
|
169
|
+
end
|
170
|
+
|
171
|
+
prediction_result
|
162
172
|
end
|
163
173
|
end
|
164
174
|
|
data/lib/dspy/version.rb
CHANGED
data/lib/dspy.rb
CHANGED
@@ -99,8 +99,21 @@ module DSPy
|
|
99
99
|
logger.info(attributes)
|
100
100
|
end
|
101
101
|
|
102
|
+
# Internal events that should not create OpenTelemetry spans
|
103
|
+
INTERNAL_EVENTS = [
|
104
|
+
'span.start',
|
105
|
+
'span.end',
|
106
|
+
'span.attributes',
|
107
|
+
'observability.disabled',
|
108
|
+
'observability.error',
|
109
|
+
'observability.span_error',
|
110
|
+
'observability.span_finish_error',
|
111
|
+
'event.span_creation_error'
|
112
|
+
].freeze
|
113
|
+
|
102
114
|
def self.create_event_span(event_name, attributes)
|
103
115
|
return unless DSPy::Observability.enabled?
|
116
|
+
return if INTERNAL_EVENTS.include?(event_name)
|
104
117
|
|
105
118
|
begin
|
106
119
|
# Flatten nested hashes for OpenTelemetry span attributes
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dspy
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.24.
|
4
|
+
version: 0.24.2
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Vicente Reig Rincón de Arellano
|
@@ -57,14 +57,14 @@ dependencies:
|
|
57
57
|
requirements:
|
58
58
|
- - "~>"
|
59
59
|
- !ruby/object:Gem::Version
|
60
|
-
version: 0.
|
60
|
+
version: 0.22.0
|
61
61
|
type: :runtime
|
62
62
|
prerelease: false
|
63
63
|
version_requirements: !ruby/object:Gem::Requirement
|
64
64
|
requirements:
|
65
65
|
- - "~>"
|
66
66
|
- !ruby/object:Gem::Version
|
67
|
-
version: 0.
|
67
|
+
version: 0.22.0
|
68
68
|
- !ruby/object:Gem::Dependency
|
69
69
|
name: anthropic
|
70
70
|
requirement: !ruby/object:Gem::Requirement
|