dspy 0.27.0 → 0.27.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/lib/dspy/chain_of_thought.rb +29 -37
- data/lib/dspy/code_act.rb +2 -2
- data/lib/dspy/context.rb +96 -37
- data/lib/dspy/errors.rb +2 -0
- data/lib/dspy/lm/adapters/gemini/schema_converter.rb +37 -35
- data/lib/dspy/lm/adapters/gemini_adapter.rb +45 -21
- data/lib/dspy/lm/adapters/openai/schema_converter.rb +70 -40
- data/lib/dspy/lm/adapters/openai_adapter.rb +35 -8
- data/lib/dspy/lm/retry_handler.rb +15 -6
- data/lib/dspy/lm/strategies/gemini_structured_output_strategy.rb +21 -8
- data/lib/dspy/lm.rb +54 -11
- data/lib/dspy/memory/local_embedding_engine.rb +27 -11
- data/lib/dspy/memory/memory_manager.rb +26 -9
- data/lib/dspy/mixins/type_coercion.rb +30 -0
- data/lib/dspy/module.rb +20 -2
- data/lib/dspy/observability/observation_type.rb +65 -0
- data/lib/dspy/observability.rb +7 -0
- data/lib/dspy/predict.rb +22 -36
- data/lib/dspy/re_act.rb +5 -3
- data/lib/dspy/tools/base.rb +57 -85
- data/lib/dspy/tools/github_cli_toolset.rb +437 -0
- data/lib/dspy/tools/toolset.rb +33 -60
- data/lib/dspy/type_system/sorbet_json_schema.rb +263 -0
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +1 -0
- metadata +5 -3
- data/lib/dspy/lm/cache_manager.rb +0 -151
@@ -1,7 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
3
|
require "sorbet-runtime"
|
4
|
-
require_relative "../../cache_manager"
|
5
4
|
|
6
5
|
module DSPy
|
7
6
|
class LM
|
@@ -22,22 +21,12 @@ module DSPy
|
|
22
21
|
|
23
22
|
sig { params(signature_class: T.class_of(DSPy::Signature), name: T.nilable(String), strict: T::Boolean).returns(T::Hash[Symbol, T.untyped]) }
|
24
23
|
def self.to_openai_format(signature_class, name: nil, strict: true)
|
25
|
-
# Build cache params from the method parameters
|
26
|
-
cache_params = { strict: strict }
|
27
|
-
cache_params[:name] = name if name
|
28
|
-
|
29
|
-
# Check cache first
|
30
|
-
cache_manager = DSPy::LM.cache_manager
|
31
|
-
cached_schema = cache_manager.get_schema(signature_class, "openai", cache_params)
|
32
|
-
|
33
|
-
if cached_schema
|
34
|
-
DSPy.logger.debug("Using cached schema for #{signature_class.name}")
|
35
|
-
return cached_schema
|
36
|
-
end
|
37
|
-
|
38
24
|
# Get the output JSON schema from the signature class
|
39
25
|
output_schema = signature_class.output_json_schema
|
40
26
|
|
27
|
+
# Convert oneOf to anyOf where safe, or raise error for unsupported cases
|
28
|
+
output_schema = convert_oneof_to_anyof_if_safe(output_schema)
|
29
|
+
|
41
30
|
# Build the complete schema with OpenAI-specific modifications
|
42
31
|
dspy_schema = {
|
43
32
|
"$schema": "http://json-schema.org/draft-06/schema#",
|
@@ -59,7 +48,7 @@ module DSPy
|
|
59
48
|
end
|
60
49
|
|
61
50
|
# Wrap in OpenAI's required format
|
62
|
-
|
51
|
+
{
|
63
52
|
type: "json_schema",
|
64
53
|
json_schema: {
|
65
54
|
name: schema_name,
|
@@ -67,34 +56,75 @@ module DSPy
|
|
67
56
|
schema: openai_schema
|
68
57
|
}
|
69
58
|
}
|
59
|
+
end
|
60
|
+
|
61
|
+
# Convert oneOf to anyOf if safe (discriminated unions), otherwise raise error
|
62
|
+
sig { params(schema: T.untyped).returns(T.untyped) }
|
63
|
+
def self.convert_oneof_to_anyof_if_safe(schema)
|
64
|
+
return schema unless schema.is_a?(Hash)
|
65
|
+
|
66
|
+
result = schema.dup
|
67
|
+
|
68
|
+
# Check if this schema has oneOf that we can safely convert
|
69
|
+
if result[:oneOf]
|
70
|
+
if all_have_discriminators?(result[:oneOf])
|
71
|
+
# Safe to convert - discriminators ensure mutual exclusivity
|
72
|
+
result[:anyOf] = result.delete(:oneOf).map { |s| convert_oneof_to_anyof_if_safe(s) }
|
73
|
+
else
|
74
|
+
# Unsafe conversion - raise error
|
75
|
+
raise DSPy::UnsupportedSchemaError.new(
|
76
|
+
"OpenAI structured outputs do not support oneOf schemas without discriminator fields. " \
|
77
|
+
"The schema contains union types that cannot be safely converted to anyOf. " \
|
78
|
+
"Please use enhanced_prompting strategy instead or add discriminator fields to union types."
|
79
|
+
)
|
80
|
+
end
|
81
|
+
end
|
82
|
+
|
83
|
+
# Recursively process nested schemas
|
84
|
+
if result[:properties].is_a?(Hash)
|
85
|
+
result[:properties] = result[:properties].transform_values { |v| convert_oneof_to_anyof_if_safe(v) }
|
86
|
+
end
|
70
87
|
|
71
|
-
|
72
|
-
|
88
|
+
if result[:items].is_a?(Hash)
|
89
|
+
result[:items] = convert_oneof_to_anyof_if_safe(result[:items])
|
90
|
+
end
|
91
|
+
|
92
|
+
# Process arrays of schema items
|
93
|
+
if result[:items].is_a?(Array)
|
94
|
+
result[:items] = result[:items].map { |item|
|
95
|
+
item.is_a?(Hash) ? convert_oneof_to_anyof_if_safe(item) : item
|
96
|
+
}
|
97
|
+
end
|
98
|
+
|
99
|
+
# Process anyOf arrays (in case there are nested oneOf within anyOf)
|
100
|
+
if result[:anyOf].is_a?(Array)
|
101
|
+
result[:anyOf] = result[:anyOf].map { |item|
|
102
|
+
item.is_a?(Hash) ? convert_oneof_to_anyof_if_safe(item) : item
|
103
|
+
}
|
104
|
+
end
|
73
105
|
|
74
106
|
result
|
75
107
|
end
|
108
|
+
|
109
|
+
# Check if all schemas in a oneOf array have discriminator fields (const properties)
|
110
|
+
sig { params(schemas: T::Array[T.untyped]).returns(T::Boolean) }
|
111
|
+
def self.all_have_discriminators?(schemas)
|
112
|
+
schemas.all? do |schema|
|
113
|
+
next false unless schema.is_a?(Hash)
|
114
|
+
next false unless schema[:properties].is_a?(Hash)
|
115
|
+
|
116
|
+
# Check if any property has a const value (our discriminator pattern)
|
117
|
+
schema[:properties].any? { |_, prop| prop.is_a?(Hash) && prop[:const] }
|
118
|
+
end
|
119
|
+
end
|
76
120
|
|
77
121
|
sig { params(model: String).returns(T::Boolean) }
|
78
122
|
def self.supports_structured_outputs?(model)
|
79
|
-
# Check cache first
|
80
|
-
cache_manager = DSPy::LM.cache_manager
|
81
|
-
cached_result = cache_manager.get_capability(model, "structured_outputs")
|
82
|
-
|
83
|
-
if !cached_result.nil?
|
84
|
-
DSPy.logger.debug("Using cached capability check for #{model}")
|
85
|
-
return cached_result
|
86
|
-
end
|
87
|
-
|
88
123
|
# Extract base model name without provider prefix
|
89
124
|
base_model = model.sub(/^openai\//, "")
|
90
125
|
|
91
126
|
# Check if it's a supported model or a newer version
|
92
|
-
|
93
|
-
|
94
|
-
# Cache the result
|
95
|
-
cache_manager.cache_capability(model, "structured_outputs", result)
|
96
|
-
|
97
|
-
result
|
127
|
+
STRUCTURED_OUTPUT_MODELS.any? { |supported| base_model.start_with?(supported) }
|
98
128
|
end
|
99
129
|
|
100
130
|
sig { params(schema: T::Hash[Symbol, T.untyped]).returns(T::Array[String]) }
|
@@ -226,8 +256,8 @@ module DSPy
|
|
226
256
|
end
|
227
257
|
end
|
228
258
|
|
229
|
-
# Process
|
230
|
-
[:
|
259
|
+
# Process anyOf/allOf (oneOf should be converted to anyOf by this point)
|
260
|
+
[:anyOf, :allOf].each do |key|
|
231
261
|
if result[key].is_a?(Array)
|
232
262
|
result[key] = result[key].map do |sub_schema|
|
233
263
|
sub_schema.is_a?(Hash) ? add_additional_properties_recursively(sub_schema) : sub_schema
|
@@ -272,8 +302,8 @@ module DSPy
|
|
272
302
|
max_depth = [max_depth, items_depth].max
|
273
303
|
end
|
274
304
|
|
275
|
-
# Check
|
276
|
-
[:
|
305
|
+
# Check anyOf/allOf (oneOf should be converted to anyOf by this point)
|
306
|
+
[:anyOf, :allOf].each do |key|
|
277
307
|
if schema[key].is_a?(Array)
|
278
308
|
schema[key].each do |sub_schema|
|
279
309
|
if sub_schema.is_a?(Hash)
|
@@ -291,8 +321,8 @@ module DSPy
|
|
291
321
|
def self.contains_pattern_properties?(schema)
|
292
322
|
return true if schema[:patternProperties]
|
293
323
|
|
294
|
-
# Recursively check nested schemas
|
295
|
-
[:properties, :items, :
|
324
|
+
# Recursively check nested schemas (oneOf should be converted to anyOf by this point)
|
325
|
+
[:properties, :items, :anyOf, :allOf].each do |key|
|
296
326
|
value = schema[key]
|
297
327
|
case value
|
298
328
|
when Hash
|
@@ -309,8 +339,8 @@ module DSPy
|
|
309
339
|
def self.contains_conditional_schemas?(schema)
|
310
340
|
return true if schema[:if] || schema[:then] || schema[:else]
|
311
341
|
|
312
|
-
# Recursively check nested schemas
|
313
|
-
[:properties, :items, :
|
342
|
+
# Recursively check nested schemas (oneOf should be converted to anyOf by this point)
|
343
|
+
[:properties, :items, :anyOf, :allOf].each do |key|
|
314
344
|
value = schema[key]
|
315
345
|
case value
|
316
346
|
when Hash
|
@@ -24,20 +24,27 @@ module DSPy
|
|
24
24
|
normalized_messages = format_multimodal_messages(normalized_messages)
|
25
25
|
end
|
26
26
|
|
27
|
-
#
|
28
|
-
|
29
|
-
|
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.
|
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
|
-
|
68
|
-
|
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
|
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
|
-
|
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
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
345
|
-
|
346
|
-
|
347
|
-
|
348
|
-
|
349
|
-
|
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
|
-
|
352
|
-
|
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
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
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
|
-
|
99
|
-
|
100
|
-
|
101
|
-
|
102
|
-
|
103
|
-
|
104
|
-
|
105
|
-
|
106
|
-
|
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,6 +34,8 @@ 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
40
|
extract_enum_class(prop_type).deserialize(value)
|
39
41
|
when Float, ->(type) { simple_type_match?(type, Float) }
|
@@ -88,6 +90,12 @@ module DSPy
|
|
88
90
|
true
|
89
91
|
end
|
90
92
|
|
93
|
+
# Checks if a type is a hash type
|
94
|
+
sig { params(type: T.untyped).returns(T::Boolean) }
|
95
|
+
def hash_type?(type)
|
96
|
+
type.is_a?(T::Types::TypedHash)
|
97
|
+
end
|
98
|
+
|
91
99
|
# Checks if a type is a struct type
|
92
100
|
sig { params(type: T.untyped).returns(T::Boolean) }
|
93
101
|
def struct_type?(type)
|
@@ -124,6 +132,28 @@ module DSPy
|
|
124
132
|
value.map { |element| coerce_value_to_type(element, element_type) }
|
125
133
|
end
|
126
134
|
|
135
|
+
# Coerces a hash value, converting keys and values as needed
|
136
|
+
sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
|
137
|
+
def coerce_hash_value(value, prop_type)
|
138
|
+
return value unless value.is_a?(Hash)
|
139
|
+
return value unless prop_type.is_a?(T::Types::TypedHash)
|
140
|
+
|
141
|
+
key_type = prop_type.keys
|
142
|
+
value_type = prop_type.values
|
143
|
+
|
144
|
+
# Convert string keys to enum instances if key_type is an enum
|
145
|
+
result = if enum_type?(key_type)
|
146
|
+
enum_class = extract_enum_class(key_type)
|
147
|
+
value.transform_keys { |k| enum_class.deserialize(k.to_s) }
|
148
|
+
else
|
149
|
+
# For non-enum keys, coerce them to the expected type
|
150
|
+
value.transform_keys { |k| coerce_value_to_type(k, key_type) }
|
151
|
+
end
|
152
|
+
|
153
|
+
# Coerce values to their expected types
|
154
|
+
result.transform_values { |v| coerce_value_to_type(v, value_type) }
|
155
|
+
end
|
156
|
+
|
127
157
|
# Coerces a struct value from a hash
|
128
158
|
sig { params(value: T.untyped, prop_type: T.untyped).returns(T.untyped) }
|
129
159
|
def coerce_struct_value(value, prop_type)
|
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
|
-
#
|
25
|
-
|
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
|