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 +4 -4
- data/README.md +4 -0
- data/lib/dspy/events/subscribers.rb +43 -0
- data/lib/dspy/events/types.rb +218 -0
- data/lib/dspy/events.rb +83 -0
- data/lib/dspy/lm/strategies/enhanced_prompting_strategy.rb +43 -12
- data/lib/dspy/signature.rb +21 -0
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +91 -1
- metadata +5 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: e7897711c81ee7a4b72dd86a8fbeee9faf4cdf3e398b9878435e0da5750b0fc1
|
4
|
+
data.tar.gz: '079cfdea700a852a94d08415bbdbbe2ff4c4e7d61e4dfae5e739fb88dec29c79'
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
data/lib/dspy/events.rb
ADDED
@@ -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] =
|
118
|
-
|
119
|
-
|
120
|
-
|
121
|
-
|
122
|
-
|
123
|
-
|
124
|
-
|
125
|
-
|
126
|
-
|
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
|
-
|
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) }
|
data/lib/dspy/signature.rb
CHANGED
@@ -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
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] =
|
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.
|
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-
|
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
|