dspy 0.10.1 → 0.12.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 +5 -2
- data/lib/dspy/errors.rb +4 -0
- data/lib/dspy/example.rb +11 -9
- data/lib/dspy/lm/message_builder.rb +28 -0
- data/lib/dspy/lm.rb +101 -36
- data/lib/dspy/prediction.rb +48 -2
- data/lib/dspy/re_act.rb +3 -3
- data/lib/dspy/signature.rb +13 -0
- data/lib/dspy/type_serializer.rb +55 -0
- data/lib/dspy/version.rb +1 -1
- data/lib/dspy.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d40d9681a87073c6c4f5531025891bf871bee086434ee131e15a4391cba7b893
|
4
|
+
data.tar.gz: 1a1a4ce935e7cdf4ce9c06617f124ed8858d7074a3f6bec4484d6db24dbfa232
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 2cdd3ee7e867344b4e45dee7fd0d73dc875ec2c730a2cf75e5f620a19c535f0aac50cc6c88dfb7beb3e075b147e7467ccd4e55a7724f49f3ac111eb080fd88e0
|
7
|
+
data.tar.gz: f5c5ed5dd3b6aa5401c20e31fbbd9638eb1d8c0f3b7fe5adb2e2b69bbb3b051d0ecd277d6e2f3b4d4896fd19f39a89345c9503d97106a3b8e005b1f55a3e5b2b
|
data/README.md
CHANGED
@@ -1,5 +1,8 @@
|
|
1
1
|
# DSPy.rb
|
2
2
|
|
3
|
+
[](https://rubygems.org/gems/dspy)
|
4
|
+
[](https://rubygems.org/gems/dspy)
|
5
|
+
|
3
6
|
**Build reliable LLM applications in Ruby using composable, type-safe modules.**
|
4
7
|
|
5
8
|
DSPy.rb brings structured LLM programming to Ruby developers. Instead of wrestling with prompt strings and parsing responses, you define typed signatures and compose them into pipelines that just work.
|
@@ -43,7 +46,7 @@ The result? LLM applications that actually scale and don't break when you sneeze
|
|
43
46
|
|
44
47
|
## Development Status
|
45
48
|
|
46
|
-
DSPy.rb is actively developed and approaching stability at **v0.
|
49
|
+
DSPy.rb is actively developed and approaching stability at **v0.10.1**. The core framework is production-ready with comprehensive documentation, but I'm battle-testing features through the 0.x series before committing to a stable v1.0 API.
|
47
50
|
|
48
51
|
Real-world usage feedback is invaluable - if you encounter issues or have suggestions, please open a GitHub issue!
|
49
52
|
|
@@ -174,7 +177,7 @@ DSPy.rb has rapidly evolved from experimental to production-ready:
|
|
174
177
|
|
175
178
|
## Roadmap - Battle-Testing Toward v1.0
|
176
179
|
|
177
|
-
DSPy.rb is currently at **v0.
|
180
|
+
DSPy.rb is currently at **v0.10.1** and approaching stability. I'm focusing on real-world usage and refinement through the 0.11, 0.12+ series before committing to a stable v1.0 API.
|
178
181
|
|
179
182
|
**Current Focus Areas:**
|
180
183
|
- 🚧 **Ollama Support** - Local model integration
|
data/lib/dspy/errors.rb
CHANGED
data/lib/dspy/example.rb
CHANGED
@@ -77,6 +77,17 @@ module DSPy
|
|
77
77
|
expected_hash
|
78
78
|
end
|
79
79
|
|
80
|
+
# Custom equality comparison
|
81
|
+
sig { params(other: T.untyped).returns(T::Boolean) }
|
82
|
+
def ==(other)
|
83
|
+
return false unless other.is_a?(Example)
|
84
|
+
|
85
|
+
@signature_class == other.signature_class &&
|
86
|
+
input_values == other.input_values &&
|
87
|
+
expected_values == other.expected_values
|
88
|
+
end
|
89
|
+
|
90
|
+
|
80
91
|
# Check if prediction matches expected output using struct comparison
|
81
92
|
sig { params(prediction: T.untyped).returns(T::Boolean) }
|
82
93
|
def matches_prediction?(prediction)
|
@@ -179,15 +190,6 @@ module DSPy
|
|
179
190
|
examples
|
180
191
|
end
|
181
192
|
|
182
|
-
# Equality comparison
|
183
|
-
sig { params(other: T.untyped).returns(T::Boolean) }
|
184
|
-
def ==(other)
|
185
|
-
return false unless other.is_a?(Example)
|
186
|
-
|
187
|
-
@signature_class == other.signature_class &&
|
188
|
-
input_values == other.input_values &&
|
189
|
-
expected_values == other.expected_values
|
190
|
-
end
|
191
193
|
|
192
194
|
# String representation for debugging
|
193
195
|
sig { returns(String) }
|
@@ -0,0 +1,28 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module DSPy
|
4
|
+
class LM
|
5
|
+
class MessageBuilder
|
6
|
+
attr_reader :messages
|
7
|
+
|
8
|
+
def initialize
|
9
|
+
@messages = []
|
10
|
+
end
|
11
|
+
|
12
|
+
def system(content)
|
13
|
+
@messages << { role: 'system', content: content.to_s }
|
14
|
+
self
|
15
|
+
end
|
16
|
+
|
17
|
+
def user(content)
|
18
|
+
@messages << { role: 'user', content: content.to_s }
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
def assistant(content)
|
23
|
+
@messages << { role: 'assistant', content: content.to_s }
|
24
|
+
self
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
end
|
data/lib/dspy/lm.rb
CHANGED
@@ -18,6 +18,9 @@ require_relative 'lm/adapters/anthropic_adapter'
|
|
18
18
|
require_relative 'lm/strategy_selector'
|
19
19
|
require_relative 'lm/retry_handler'
|
20
20
|
|
21
|
+
# Load message builder
|
22
|
+
require_relative 'lm/message_builder'
|
23
|
+
|
21
24
|
module DSPy
|
22
25
|
class LM
|
23
26
|
attr_reader :model_id, :api_key, :model, :provider, :adapter
|
@@ -39,41 +42,13 @@ module DSPy
|
|
39
42
|
# Build messages from inference module
|
40
43
|
messages = build_messages(inference_module, input_values)
|
41
44
|
|
42
|
-
#
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
# Use smart consolidation: emit LM events only when not in nested context
|
47
|
-
response = nil
|
48
|
-
token_usage = {}
|
45
|
+
# Execute with instrumentation
|
46
|
+
response = instrument_lm_request(messages, signature_class.name) do
|
47
|
+
chat_with_strategy(messages, signature_class, &block)
|
48
|
+
end
|
49
49
|
|
50
|
+
# Instrument response parsing
|
50
51
|
if should_emit_lm_events?
|
51
|
-
# Emit all LM events when not in nested context
|
52
|
-
response = Instrumentation.instrument('dspy.lm.request', {
|
53
|
-
gen_ai_operation_name: 'chat',
|
54
|
-
gen_ai_system: provider,
|
55
|
-
gen_ai_request_model: model,
|
56
|
-
signature_class: signature_class.name,
|
57
|
-
provider: provider,
|
58
|
-
adapter_class: adapter.class.name,
|
59
|
-
input_size: input_size
|
60
|
-
}) do
|
61
|
-
chat_with_strategy(messages, signature_class, &block)
|
62
|
-
end
|
63
|
-
|
64
|
-
# Extract actual token usage from response (more accurate than estimation)
|
65
|
-
token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
|
66
|
-
|
67
|
-
# Emit token usage event if available
|
68
|
-
if token_usage.any?
|
69
|
-
Instrumentation.emit('dspy.lm.tokens', token_usage.merge({
|
70
|
-
gen_ai_system: provider,
|
71
|
-
gen_ai_request_model: model,
|
72
|
-
signature_class: signature_class.name
|
73
|
-
}))
|
74
|
-
end
|
75
|
-
|
76
|
-
# Instrument response parsing
|
77
52
|
parsed_result = Instrumentation.instrument('dspy.lm.response.parsed', {
|
78
53
|
signature_class: signature_class.name,
|
79
54
|
provider: provider,
|
@@ -82,15 +57,33 @@ module DSPy
|
|
82
57
|
parse_response(response, input_values, signature_class)
|
83
58
|
end
|
84
59
|
else
|
85
|
-
# Consolidated mode: execute without nested instrumentation
|
86
|
-
response = chat_with_strategy(messages, signature_class, &block)
|
87
|
-
token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
|
88
60
|
parsed_result = parse_response(response, input_values, signature_class)
|
89
61
|
end
|
90
62
|
|
91
63
|
parsed_result
|
92
64
|
end
|
93
65
|
|
66
|
+
def raw_chat(messages = nil, &block)
|
67
|
+
# Support both array format and builder DSL
|
68
|
+
if block_given? && messages.nil?
|
69
|
+
# DSL mode - block is for building messages
|
70
|
+
builder = MessageBuilder.new
|
71
|
+
yield builder
|
72
|
+
messages = builder.messages
|
73
|
+
streaming_block = nil
|
74
|
+
else
|
75
|
+
# Array mode - block is for streaming
|
76
|
+
messages ||= []
|
77
|
+
streaming_block = block
|
78
|
+
end
|
79
|
+
|
80
|
+
# Validate messages format
|
81
|
+
validate_messages!(messages)
|
82
|
+
|
83
|
+
# Execute with instrumentation
|
84
|
+
execute_raw_chat(messages, &streaming_block)
|
85
|
+
end
|
86
|
+
|
94
87
|
private
|
95
88
|
|
96
89
|
def chat_with_strategy(messages, signature_class, &block)
|
@@ -208,5 +201,77 @@ module DSPy
|
|
208
201
|
raise "Failed to parse LLM response as JSON: #{e.message}. Original content length: #{response.content&.length || 0} chars"
|
209
202
|
end
|
210
203
|
end
|
204
|
+
|
205
|
+
# Common instrumentation method for LM requests
|
206
|
+
def instrument_lm_request(messages, signature_class_name, &execution_block)
|
207
|
+
input_text = messages.map { |m| m[:content] }.join(' ')
|
208
|
+
input_size = input_text.length
|
209
|
+
|
210
|
+
response = nil
|
211
|
+
|
212
|
+
if should_emit_lm_events?
|
213
|
+
# Emit dspy.lm.request event
|
214
|
+
response = Instrumentation.instrument('dspy.lm.request', {
|
215
|
+
gen_ai_operation_name: 'chat',
|
216
|
+
gen_ai_system: provider,
|
217
|
+
gen_ai_request_model: model,
|
218
|
+
signature_class: signature_class_name,
|
219
|
+
provider: provider,
|
220
|
+
adapter_class: adapter.class.name,
|
221
|
+
input_size: input_size
|
222
|
+
}, &execution_block)
|
223
|
+
|
224
|
+
# Extract and emit token usage
|
225
|
+
emit_token_usage(response, signature_class_name)
|
226
|
+
else
|
227
|
+
# Consolidated mode: execute without instrumentation
|
228
|
+
response = execution_block.call
|
229
|
+
end
|
230
|
+
|
231
|
+
response
|
232
|
+
end
|
233
|
+
|
234
|
+
# Common method to emit token usage events
|
235
|
+
def emit_token_usage(response, signature_class_name)
|
236
|
+
token_usage = Instrumentation::TokenTracker.extract_token_usage(response, provider)
|
237
|
+
|
238
|
+
if token_usage.any?
|
239
|
+
Instrumentation.emit('dspy.lm.tokens', token_usage.merge({
|
240
|
+
gen_ai_system: provider,
|
241
|
+
gen_ai_request_model: model,
|
242
|
+
signature_class: signature_class_name
|
243
|
+
}))
|
244
|
+
end
|
245
|
+
|
246
|
+
token_usage
|
247
|
+
end
|
248
|
+
|
249
|
+
def validate_messages!(messages)
|
250
|
+
unless messages.is_a?(Array)
|
251
|
+
raise ArgumentError, "messages must be an array"
|
252
|
+
end
|
253
|
+
|
254
|
+
valid_roles = %w[system user assistant]
|
255
|
+
|
256
|
+
messages.each do |message|
|
257
|
+
unless message.is_a?(Hash) && message.key?(:role) && message.key?(:content)
|
258
|
+
raise ArgumentError, "Each message must have :role and :content"
|
259
|
+
end
|
260
|
+
|
261
|
+
unless valid_roles.include?(message[:role])
|
262
|
+
raise ArgumentError, "Invalid role: #{message[:role]}. Must be one of: #{valid_roles.join(', ')}"
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
def execute_raw_chat(messages, &streaming_block)
|
268
|
+
response = instrument_lm_request(messages, 'RawPrompt') do
|
269
|
+
# Direct adapter call, no strategies or JSON parsing
|
270
|
+
adapter.chat(messages: messages, signature: nil, &streaming_block)
|
271
|
+
end
|
272
|
+
|
273
|
+
# Return raw response content, not parsed JSON
|
274
|
+
response.content
|
275
|
+
end
|
211
276
|
end
|
212
277
|
end
|
data/lib/dspy/prediction.rb
CHANGED
@@ -319,6 +319,9 @@ module DSPy
|
|
319
319
|
end
|
320
320
|
|
321
321
|
value.each do |k, v|
|
322
|
+
# Skip _type field from being added to the struct (it's not a real field)
|
323
|
+
next if k == :_type || k == "_type"
|
324
|
+
|
322
325
|
prop_info = struct_class.props[k]
|
323
326
|
if prop_info
|
324
327
|
prop_type = prop_info[:type_object] || prop_info[:type]
|
@@ -343,7 +346,27 @@ module DSPy
|
|
343
346
|
value
|
344
347
|
end
|
345
348
|
when T::Types::Union
|
346
|
-
#
|
349
|
+
# Check if value has a _type field for automatic type detection
|
350
|
+
type_name = value[:_type] || value["_type"]
|
351
|
+
|
352
|
+
if type_name
|
353
|
+
# Use _type field to determine which struct to instantiate
|
354
|
+
type.types.each do |t|
|
355
|
+
next if t == T::Utils.coerce(NilClass)
|
356
|
+
|
357
|
+
if t.is_a?(T::Types::Simple) && t.raw_type < T::Struct
|
358
|
+
struct_name = t.raw_type.name.split('::').last
|
359
|
+
if struct_name == type_name
|
360
|
+
return convert_to_struct(value, t)
|
361
|
+
end
|
362
|
+
end
|
363
|
+
end
|
364
|
+
|
365
|
+
# If no matching type found, raise an error
|
366
|
+
raise DSPy::DeserializationError, "Unknown type: #{type_name}. Expected one of: #{type.types.map { |t| t.is_a?(T::Types::Simple) && t.raw_type < T::Struct ? t.raw_type.name.split('::').last : nil }.compact.join(', ')}"
|
367
|
+
end
|
368
|
+
|
369
|
+
# Fallback to trying each type if no _type field
|
347
370
|
type.types.each do |t|
|
348
371
|
next if t == T::Utils.coerce(NilClass)
|
349
372
|
|
@@ -398,7 +421,27 @@ module DSPy
|
|
398
421
|
|
399
422
|
sig { params(hash: T::Hash[Symbol, T.untyped], union_type: T::Types::Union).returns(T.untyped) }
|
400
423
|
def convert_hash_to_union_struct(hash, union_type)
|
401
|
-
#
|
424
|
+
# First check if hash has a _type field for automatic type detection
|
425
|
+
type_name = hash[:_type] || hash["_type"]
|
426
|
+
|
427
|
+
if type_name
|
428
|
+
# Use _type field to determine which struct to instantiate
|
429
|
+
union_type.types.each do |type|
|
430
|
+
next if type == T::Utils.coerce(NilClass)
|
431
|
+
|
432
|
+
if type.is_a?(T::Types::Simple) && type.raw_type < T::Struct
|
433
|
+
struct_name = type.raw_type.name.split('::').last
|
434
|
+
if struct_name == type_name
|
435
|
+
return convert_to_struct(hash, type)
|
436
|
+
end
|
437
|
+
end
|
438
|
+
end
|
439
|
+
|
440
|
+
# If no matching type found, raise an error
|
441
|
+
raise DSPy::DeserializationError, "Unknown type: #{type_name}. Expected one of: #{union_type.types.map { |t| t.is_a?(T::Types::Simple) && t.raw_type < T::Struct ? t.raw_type.name.split('::').last : nil }.compact.join(', ')}"
|
442
|
+
end
|
443
|
+
|
444
|
+
# Fallback: Try to match the hash structure to one of the union types
|
402
445
|
union_type.types.each do |type|
|
403
446
|
next if type == T::Utils.coerce(NilClass)
|
404
447
|
|
@@ -412,6 +455,9 @@ module DSPy
|
|
412
455
|
# Need to convert nested values too
|
413
456
|
converted_hash = {}
|
414
457
|
hash.each do |k, v|
|
458
|
+
# Skip _type field
|
459
|
+
next if k == :_type || k == "_type"
|
460
|
+
|
415
461
|
prop_info = struct_class.props[k]
|
416
462
|
if prop_info
|
417
463
|
prop_type = prop_info[:type_object] || prop_info[:type]
|
data/lib/dspy/re_act.rb
CHANGED
@@ -256,7 +256,7 @@ module DSPy
|
|
256
256
|
}) do
|
257
257
|
# Generate thought and action
|
258
258
|
thought_obj = @thought_generator.forward(
|
259
|
-
input_context:
|
259
|
+
input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
|
260
260
|
history: history,
|
261
261
|
available_tools: available_tools_desc
|
262
262
|
)
|
@@ -385,7 +385,7 @@ module DSPy
|
|
385
385
|
return { should_finish: false } if observation.include?("Unknown action")
|
386
386
|
|
387
387
|
observation_result = @observation_processor.forward(
|
388
|
-
input_context:
|
388
|
+
input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
|
389
389
|
history: history,
|
390
390
|
observation: observation
|
391
391
|
)
|
@@ -402,7 +402,7 @@ module DSPy
|
|
402
402
|
sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], observation_result: T.untyped, iteration: Integer).returns(String) }
|
403
403
|
def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
|
404
404
|
final_thought = @thought_generator.forward(
|
405
|
-
input_context:
|
405
|
+
input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
|
406
406
|
history: history,
|
407
407
|
available_tools: available_tools_desc
|
408
408
|
)
|
data/lib/dspy/signature.rb
CHANGED
@@ -292,6 +292,19 @@ module DSPy
|
|
292
292
|
properties = {}
|
293
293
|
required = []
|
294
294
|
|
295
|
+
# Check if struct already has a _type field
|
296
|
+
if struct_class.props.key?(:_type)
|
297
|
+
raise DSPy::ValidationError, "_type field conflict: #{struct_class.name} already has a _type field defined. " \
|
298
|
+
"DSPy uses _type for automatic type detection in union types."
|
299
|
+
end
|
300
|
+
|
301
|
+
# Add automatic _type field for type detection
|
302
|
+
properties[:_type] = {
|
303
|
+
type: "string",
|
304
|
+
const: struct_class.name.split('::').last # Use the simple class name
|
305
|
+
}
|
306
|
+
required << "_type"
|
307
|
+
|
295
308
|
struct_class.props.each do |prop_name, prop_info|
|
296
309
|
prop_type = prop_info[:type_object] || prop_info[:type]
|
297
310
|
properties[prop_name] = type_to_json_schema(prop_type)
|
@@ -0,0 +1,55 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "sorbet-runtime"
|
4
|
+
|
5
|
+
module DSPy
|
6
|
+
class TypeSerializer
|
7
|
+
extend T::Sig
|
8
|
+
|
9
|
+
# Serialize a value, injecting _type fields for T::Struct instances
|
10
|
+
sig { params(value: T.untyped).returns(T.untyped) }
|
11
|
+
def self.serialize(value)
|
12
|
+
case value
|
13
|
+
when T::Struct
|
14
|
+
serialize_struct(value)
|
15
|
+
when Array
|
16
|
+
value.map { |item| serialize(item) }
|
17
|
+
when Hash
|
18
|
+
value.transform_values { |v| serialize(v) }
|
19
|
+
else
|
20
|
+
value
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
private
|
25
|
+
|
26
|
+
sig { params(struct: T::Struct).returns(T::Hash[String, T.untyped]) }
|
27
|
+
def self.serialize_struct(struct)
|
28
|
+
# Handle anonymous structs that don't have a name
|
29
|
+
class_name = struct.class.name
|
30
|
+
type_name = if class_name.nil? || class_name.empty?
|
31
|
+
# For anonymous structs, use a generic identifier
|
32
|
+
"AnonymousStruct"
|
33
|
+
else
|
34
|
+
class_name.split('::').last
|
35
|
+
end
|
36
|
+
|
37
|
+
result = {
|
38
|
+
"_type" => type_name
|
39
|
+
}
|
40
|
+
|
41
|
+
# Get all props and serialize their values
|
42
|
+
struct.class.props.each do |prop_name, prop_info|
|
43
|
+
prop_value = struct.send(prop_name)
|
44
|
+
|
45
|
+
# Skip nil values for optional fields
|
46
|
+
next if prop_value.nil? && prop_info[:fully_optional]
|
47
|
+
|
48
|
+
# Recursively serialize nested values
|
49
|
+
result[prop_name.to_s] = serialize(prop_value)
|
50
|
+
end
|
51
|
+
|
52
|
+
result
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
data/lib/dspy/version.rb
CHANGED
data/lib/dspy.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: dspy
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.12.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Vicente Reig Rincón de Arellano
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2025-07-
|
11
|
+
date: 2025-07-24 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: dry-configurable
|
@@ -177,6 +177,7 @@ files:
|
|
177
177
|
- lib/dspy/lm/adapters/openai_adapter.rb
|
178
178
|
- lib/dspy/lm/cache_manager.rb
|
179
179
|
- lib/dspy/lm/errors.rb
|
180
|
+
- lib/dspy/lm/message_builder.rb
|
180
181
|
- lib/dspy/lm/response.rb
|
181
182
|
- lib/dspy/lm/retry_handler.rb
|
182
183
|
- lib/dspy/lm/strategies/anthropic_extraction_strategy.rb
|
@@ -223,6 +224,7 @@ files:
|
|
223
224
|
- lib/dspy/tools/memory_toolset.rb
|
224
225
|
- lib/dspy/tools/text_processing_toolset.rb
|
225
226
|
- lib/dspy/tools/toolset.rb
|
227
|
+
- lib/dspy/type_serializer.rb
|
226
228
|
- lib/dspy/version.rb
|
227
229
|
homepage: https://github.com/vicentereig/dspy.rb
|
228
230
|
licenses:
|