dspy 0.23.0 → 0.24.1
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 +5 -5
- data/lib/dspy/chain_of_thought.rb +37 -8
- data/lib/dspy/context.rb +23 -15
- data/lib/dspy/lm/adapters/openai_adapter.rb +18 -10
- data/lib/dspy/lm.rb +30 -17
- data/lib/dspy/observability.rb +5 -1
- data/lib/dspy/predict.rb +12 -2
- data/lib/dspy/teleprompt/gepa.rb +11 -2
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +13 -0
- metadata +7 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '0946116ac08ee09e62d204db418f2d45f62eb4d4b1eff8306de1780a8cdfba8f'
|
4
|
+
data.tar.gz: a294c49d86d084940738ebb39da6c7d3b8fef15064ff2c2cb15c07a90acdec8f
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 91141a65593592604c301f3c51f48fac2c29b654c871c3155a055202d25efdcf7511ff4fda0372021f686b864f1e078e89fed27d245300f20966de62e3e295c5
|
7
|
+
data.tar.gz: e4885370fde056ff4dda5464901f0f3e460bbf0cf2985e079088adf67d091718fe72e8ee0efc34999b021bb3e238649a782f12c3fec1162a8b6b13576bd25dc0
|
data/README.md
CHANGED
@@ -5,16 +5,15 @@
|
|
5
5
|
[](https://github.com/vicentereig/dspy.rb/actions/workflows/ruby.yml)
|
6
6
|
[](https://vicentereig.github.io/dspy.rb/)
|
7
7
|
|
8
|
-
**Build reliable LLM applications in Ruby using composable, type-safe modules.**
|
8
|
+
**Build reliable LLM applications in idiomatic Ruby using composable, type-safe modules.**
|
9
9
|
|
10
|
-
DSPy.rb brings structured LLM programming to Ruby developers. Instead of wrestling with prompt strings and parsing
|
11
|
-
responses, you define typed signatures and compose them into pipelines that just work.
|
10
|
+
The Ruby framework for programming with large language models. DSPy.rb brings structured LLM programming to Ruby developers. Instead of wrestling with prompt strings and parsing responses, you define typed signatures using idiomatic Ruby to compose and decompose AI Worklows and AI Agents.
|
12
11
|
|
13
|
-
Traditional prompting is like writing code with string concatenation: it works until it doesn't. DSPy.rb brings you
|
12
|
+
**Prompts are the just Functions.** Traditional prompting is like writing code with string concatenation: it works until it doesn't. DSPy.rb brings you
|
14
13
|
the programming approach pioneered by [dspy.ai](https://dspy.ai/): instead of crafting fragile prompts, you define modular
|
15
14
|
signatures and let the framework handle the messy details.
|
16
15
|
|
17
|
-
DSPy.rb is an idiomatic Ruby port of Stanford's [DSPy framework](https://github.com/stanfordnlp/dspy). While implementing
|
16
|
+
DSPy.rb is an idiomatic Ruby surgical port of Stanford's [DSPy framework](https://github.com/stanfordnlp/dspy). While implementing
|
18
17
|
the core concepts of signatures, predictors, and optimization from the original Python library, DSPy.rb embraces Ruby
|
19
18
|
conventions and adds Ruby-specific innovations like CodeAct agents and enhanced production instrumentation.
|
20
19
|
|
@@ -192,6 +191,7 @@ DSPy.rb has rapidly evolved from experimental to production-ready:
|
|
192
191
|
- ✅ **Optimization Framework** - MIPROv2 algorithm with storage & persistence
|
193
192
|
|
194
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
|
195
195
|
- ✅ **Comprehensive Multimodal Framework** - Complete image analysis with `DSPy::Image`, type-safe bounding boxes, vision model integration
|
196
196
|
- ✅ **Advanced Type System** - `T::Enum` integration, union types for agentic workflows, complex type coercion
|
197
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: "ChainOfThought.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,45 @@ 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
|
+
DSPy::Observability.tracer.in_span(
|
47
|
+
operation,
|
48
|
+
attributes: span_attributes,
|
49
|
+
kind: :internal
|
50
|
+
) do |span|
|
51
|
+
yield(span)
|
52
|
+
end
|
53
|
+
else
|
54
|
+
yield(nil)
|
55
|
+
end
|
43
56
|
ensure
|
44
57
|
# Pop from stack
|
45
58
|
current[:span_stack].pop
|
46
59
|
|
47
|
-
# Log span end with duration
|
60
|
+
# Log span end with duration (internal logging only)
|
48
61
|
duration_ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time) * 1000).round(2)
|
49
62
|
DSPy.log('span.end',
|
50
63
|
trace_id: current[:trace_id],
|
51
64
|
span_id: span_id,
|
52
65
|
duration_ms: duration_ms
|
53
66
|
)
|
54
|
-
|
55
|
-
# Finish OpenTelemetry span
|
56
|
-
DSPy::Observability.finish_span(otel_span) if otel_span
|
57
67
|
end
|
58
|
-
|
59
|
-
result
|
60
68
|
end
|
61
69
|
|
62
70
|
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
|
|
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/teleprompt/gepa.rb
CHANGED
@@ -1699,8 +1699,17 @@ module DSPy
|
|
1699
1699
|
end
|
1700
1700
|
secondary_scores[:token_efficiency] = calculate_token_efficiency(mock_traces, predictions.size)
|
1701
1701
|
|
1702
|
-
# Response consistency
|
1703
|
-
response_texts = predictions.map
|
1702
|
+
# Response consistency - use first output field for any signature
|
1703
|
+
response_texts = predictions.map do |p|
|
1704
|
+
pred = p[:prediction]
|
1705
|
+
if pred && pred.respond_to?(:class) && pred.class.respond_to?(:props)
|
1706
|
+
# Get first output field name and value
|
1707
|
+
first_field = pred.class.props.keys.first
|
1708
|
+
first_field ? (pred.send(first_field)&.to_s || '') : ''
|
1709
|
+
else
|
1710
|
+
''
|
1711
|
+
end
|
1712
|
+
end
|
1704
1713
|
secondary_scores[:consistency] = calculate_consistency(response_texts)
|
1705
1714
|
|
1706
1715
|
# Latency performance
|
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.
|
4
|
+
version: 0.24.1
|
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
|
@@ -177,8 +177,10 @@ dependencies:
|
|
177
177
|
- - "~>"
|
178
178
|
- !ruby/object:Gem::Version
|
179
179
|
version: '0.30'
|
180
|
-
description: The Ruby framework for programming with large language models.
|
181
|
-
|
180
|
+
description: The Ruby framework for programming with large language models. DSPy.rb
|
181
|
+
brings structured LLM programming to Ruby developers. Instead of wrestling with
|
182
|
+
prompt strings and parsing responses, you define typed signatures using idiomatic
|
183
|
+
Ruby to compose and decompose AI Worklows and AI Agents.
|
182
184
|
email:
|
183
185
|
- hey@vicente.services
|
184
186
|
executables: []
|