dspy 0.27.1 → 0.27.3

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.
data/lib/dspy/re_act.rb CHANGED
@@ -66,6 +66,13 @@ module DSPy
66
66
  extend T::Sig
67
67
  include Mixins::StructBuilder
68
68
 
69
+ # AvailableTool struct for better type safety in ReAct agents
70
+ class AvailableTool < T::Struct
71
+ const :name, String
72
+ const :description, String
73
+ const :schema, T::Hash[Symbol, T.untyped]
74
+ end
75
+
69
76
  FINISH_ACTION = "finish"
70
77
  sig { returns(T.class_of(DSPy::Signature)) }
71
78
  attr_reader :original_signature_class
@@ -87,6 +94,9 @@ module DSPy
87
94
  tools.each { |tool| @tools[tool.name.downcase] = tool }
88
95
  @max_iterations = max_iterations
89
96
 
97
+ # Create dynamic ActionEnum class with tool names + finish
98
+ @action_enum_class = create_action_enum_class
99
+
90
100
  # Create dynamic signature classes that include the original input fields
91
101
  thought_signature = create_thought_signature(signature_class)
92
102
  observation_signature = create_observation_signature(signature_class)
@@ -143,9 +153,34 @@ module DSPy
143
153
 
144
154
  private
145
155
 
156
+ # Creates a dynamic ActionEnum class with tool names and "finish"
157
+ sig { returns(T.class_of(T::Enum)) }
158
+ def create_action_enum_class
159
+ tool_names = @tools.keys
160
+ all_actions = tool_names + [FINISH_ACTION]
161
+
162
+ # Create a dynamic enum class using proper T::Enum pattern
163
+ enum_class = Class.new(T::Enum)
164
+
165
+ # Build the enums block code dynamically
166
+ enum_definitions = all_actions.map do |action_name|
167
+ const_name = action_name.upcase.gsub(/[^A-Z0-9_]/, '_')
168
+ "#{const_name} = new(#{action_name.inspect})"
169
+ end.join("\n ")
170
+
171
+ enum_class.class_eval <<~RUBY
172
+ enums do
173
+ #{enum_definitions}
174
+ end
175
+ RUBY
176
+
177
+ enum_class
178
+ end
179
+
146
180
  # Creates a dynamic Thought signature that includes the original input fields
147
181
  sig { params(signature_class: T.class_of(DSPy::Signature)).returns(T.class_of(DSPy::Signature)) }
148
182
  def create_thought_signature(signature_class)
183
+ action_enum_class = @action_enum_class
149
184
  # Create new class that inherits from DSPy::Signature
150
185
  Class.new(DSPy::Signature) do
151
186
  # Set description
@@ -154,21 +189,21 @@ module DSPy
154
189
  # Define input fields
155
190
  input do
156
191
  const :input_context, String,
157
- desc: "Serialized representation of all input fields"
192
+ description: "Serialized representation of all input fields"
158
193
  const :history, T::Array[HistoryEntry],
159
- desc: "Previous thoughts and actions, including observations from tools."
160
- const :available_tools, T::Array[T::Hash[String, T.untyped]],
161
- desc: "Array of available tools with their JSON schemas."
194
+ description: "Previous thoughts and actions, including observations from tools."
195
+ const :available_tools, T::Array[AvailableTool],
196
+ description: "Array of available tools with their JSON schemas."
162
197
  end
163
198
 
164
199
  # Define output fields (same as ThoughtBase)
165
200
  output do
166
201
  const :thought, String,
167
- desc: "Reasoning about what to do next, considering the history and observations."
168
- const :action, String,
169
- desc: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
202
+ description: "Reasoning about what to do next, considering the history and observations."
203
+ const :action, action_enum_class,
204
+ description: "The action to take. MUST be one of the tool names listed in `available_tools` input, or the literal string \"finish\" to provide the final answer."
170
205
  const :action_input, T.any(String, T::Hash[T.untyped, T.untyped]),
171
- desc: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final result based on processing the input data."
206
+ description: "Input for the chosen action. If action is a tool name, this MUST be a JSON object matching the tool's schema. If action is \"finish\", this field MUST contain the final result based on processing the input data."
172
207
  end
173
208
  end
174
209
  end
@@ -184,19 +219,19 @@ module DSPy
184
219
  # Define input fields
185
220
  input do
186
221
  const :input_context, String,
187
- desc: "Serialized representation of all input fields"
222
+ description: "Serialized representation of all input fields"
188
223
  const :history, T::Array[HistoryEntry],
189
- desc: "Previous thoughts, actions, and observations."
224
+ description: "Previous thoughts, actions, and observations."
190
225
  const :observation, String,
191
- desc: "The result from the last action"
226
+ description: "The result from the last action"
192
227
  end
193
228
 
194
229
  # Define output fields (same as ReActObservationBase)
195
230
  output do
196
231
  const :interpretation, String,
197
- desc: "Interpretation of the observation"
232
+ description: "Interpretation of the observation"
198
233
  const :next_step, NextStep,
199
- desc: "What to do next: '#{NextStep::Continue}' or '#{NextStep::Finish}'"
234
+ description: "What to do next: '#{NextStep::Continue}' or '#{NextStep::Finish}'"
200
235
  end
201
236
  end
202
237
  end
@@ -205,7 +240,14 @@ module DSPy
205
240
  sig { params(input_struct: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
206
241
  def execute_react_reasoning_loop(input_struct)
207
242
  history = T.let([], T::Array[HistoryEntry])
208
- available_tools_desc = @tools.map { |name, tool| JSON.parse(tool.schema) }
243
+ available_tools_desc = @tools.map { |name, tool|
244
+ schema = JSON.parse(tool.schema)
245
+ AvailableTool.new(
246
+ name: name,
247
+ description: tool.description,
248
+ schema: schema.transform_keys(&:to_sym)
249
+ )
250
+ }
209
251
  final_answer = T.let(nil, T.nilable(String))
210
252
  iterations_count = 0
211
253
  last_observation = T.let(nil, T.nilable(String))
@@ -239,11 +281,12 @@ module DSPy
239
281
  end
240
282
 
241
283
  # Executes a single iteration of the ReAct loop
242
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
284
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], iteration: Integer, tools_used: T::Array[String], last_observation: T.nilable(String)).returns(T::Hash[Symbol, T.untyped]) }
243
285
  def execute_single_iteration(input_struct, history, available_tools_desc, iteration, tools_used, last_observation)
244
- # Track each iteration with span
286
+ # Track each iteration with agent span
245
287
  DSPy::Context.with_span(
246
288
  operation: 'react.iteration',
289
+ **DSPy::ObservationType::Agent.langfuse_attributes,
247
290
  'dspy.module' => 'ReAct',
248
291
  'react.iteration' => iteration,
249
292
  'react.max_iterations' => @max_iterations,
@@ -271,12 +314,15 @@ module DSPy
271
314
  thought_obj.action, thought_obj.action_input, iteration
272
315
  )
273
316
 
317
+ # Convert action enum to string for processing and storage
318
+ action_str = thought_obj.action.respond_to?(:serialize) ? thought_obj.action.serialize : thought_obj.action.to_s
319
+
274
320
  # Track tools used
275
- tools_used << thought_obj.action.downcase if valid_tool?(thought_obj.action)
321
+ tools_used << action_str.downcase if valid_tool?(thought_obj.action)
276
322
 
277
323
  # Add to history
278
324
  history << create_history_entry(
279
- iteration, thought_obj.thought, thought_obj.action,
325
+ iteration, thought_obj.thought, action_str,
280
326
  thought_obj.action_input, observation
281
327
  )
282
328
 
@@ -290,7 +336,7 @@ module DSPy
290
336
  end
291
337
 
292
338
  emit_iteration_complete_event(
293
- iteration, thought_obj.thought, thought_obj.action,
339
+ iteration, thought_obj.thought, action_str,
294
340
  thought_obj.action_input, observation, tools_used
295
341
  )
296
342
 
@@ -340,30 +386,39 @@ module DSPy
340
386
  final_answer.nil? && (@max_iterations.nil? || iterations_count < @max_iterations)
341
387
  end
342
388
 
343
- sig { params(action: T.nilable(String)).returns(T::Boolean) }
389
+ sig { params(action: T.nilable(T.any(String, T::Enum))).returns(T::Boolean) }
344
390
  def finish_action?(action)
345
- action&.downcase == FINISH_ACTION
391
+ return false unless action
392
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
393
+ action_str.downcase == FINISH_ACTION
346
394
  end
347
395
 
348
- sig { params(action: T.nilable(String)).returns(T::Boolean) }
396
+ sig { params(action: T.nilable(T.any(String, T::Enum))).returns(T::Boolean) }
349
397
  def valid_tool?(action)
350
- !!(action && @tools[action.downcase])
398
+ return false unless action
399
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
400
+ !!@tools[action_str.downcase]
351
401
  end
352
402
 
353
- sig { params(action: T.nilable(String), action_input: T.untyped, iteration: Integer).returns(String) }
403
+ sig { params(action: T.nilable(T.any(String, T::Enum)), action_input: T.untyped, iteration: Integer).returns(String) }
354
404
  def execute_tool_with_instrumentation(action, action_input, iteration)
355
- if action && @tools[action.downcase]
405
+ return "Unknown action: #{action}. Available actions: #{@tools.keys.join(', ')}, finish" unless action
406
+
407
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
408
+
409
+ if @tools[action_str.downcase]
356
410
  DSPy::Context.with_span(
357
411
  operation: 'react.tool_call',
412
+ **DSPy::ObservationType::Tool.langfuse_attributes,
358
413
  'dspy.module' => 'ReAct',
359
414
  'react.iteration' => iteration,
360
- 'tool.name' => action.downcase,
415
+ 'tool.name' => action_str.downcase,
361
416
  'tool.input' => action_input
362
417
  ) do
363
- execute_action(action, action_input)
418
+ execute_action(action_str, action_input)
364
419
  end
365
420
  else
366
- "Unknown action: #{action}. Available actions: #{@tools.keys.join(', ')}, finish"
421
+ "Unknown action: #{action_str}. Available actions: #{@tools.keys.join(', ')}, finish"
367
422
  end
368
423
  end
369
424
 
@@ -378,7 +433,7 @@ module DSPy
378
433
  )
379
434
  end
380
435
 
381
- sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: String, available_tools_desc: T::Array[T::Hash[String, T.untyped]], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
436
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], observation: String, available_tools_desc: T::Array[AvailableTool], iteration: Integer).returns(T::Hash[Symbol, T.untyped]) }
382
437
  def process_observation_and_decide_next_step(input_struct, history, observation, available_tools_desc, iteration)
383
438
  return { should_finish: false } if observation.include?("Unknown action")
384
439
 
@@ -397,7 +452,7 @@ module DSPy
397
452
  { should_finish: true, final_answer: final_answer }
398
453
  end
399
454
 
400
- 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) }
455
+ sig { params(input_struct: T.untyped, history: T::Array[HistoryEntry], available_tools_desc: T::Array[AvailableTool], observation_result: T.untyped, iteration: Integer).returns(String) }
401
456
  def generate_forced_final_answer(input_struct, history, available_tools_desc, observation_result, iteration)
402
457
  final_thought = @thought_generator.forward(
403
458
  input_context: DSPy::TypeSerializer.serialize(input_struct).to_json,
@@ -405,7 +460,8 @@ module DSPy
405
460
  available_tools: available_tools_desc
406
461
  )
407
462
 
408
- if final_thought.action&.downcase != FINISH_ACTION
463
+ action_str = final_thought.action.respond_to?(:serialize) ? final_thought.action.serialize : final_thought.action.to_s
464
+ if action_str.downcase != FINISH_ACTION
409
465
  forced_answer = if observation_result.interpretation && !observation_result.interpretation.empty?
410
466
  observation_result.interpretation
411
467
  else
@@ -419,7 +475,7 @@ module DSPy
419
475
 
420
476
  sig { params(iteration: Integer, thought: String, action: String, action_input: T.untyped, observation: String, tools_used: T::Array[String]).void }
421
477
  def emit_iteration_complete_event(iteration, thought, action, action_input, observation, tools_used)
422
- DSPy.log('react.iteration_complete', **{
478
+ DSPy.event('react.iteration_complete', {
423
479
  'react.iteration' => iteration,
424
480
  'react.thought' => thought,
425
481
  'react.action' => action,
@@ -432,7 +488,7 @@ module DSPy
432
488
  sig { params(iterations_count: Integer, final_answer: T.nilable(String), tools_used: T::Array[String], history: T::Array[HistoryEntry]).void }
433
489
  def handle_max_iterations_if_needed(iterations_count, final_answer, tools_used, history)
434
490
  if iterations_count >= @max_iterations && final_answer.nil?
435
- DSPy.log('react.max_iterations', **{
491
+ DSPy.event('react.max_iterations', {
436
492
  'react.iteration_count' => iterations_count,
437
493
  'react.max_iterations' => @max_iterations,
438
494
  'react.tools_used' => tools_used.uniq,
@@ -514,7 +570,7 @@ module DSPy
514
570
  example
515
571
  end
516
572
 
517
- sig { params(action_input: T.untyped, last_observation: T.nilable(String), step: Integer, thought: String, action: String, history: T::Array[HistoryEntry]).returns(String) }
573
+ sig { params(action_input: T.untyped, last_observation: T.nilable(String), step: Integer, thought: String, action: T.any(String, T::Enum), history: T::Array[HistoryEntry]).returns(String) }
518
574
  def handle_finish_action(action_input, last_observation, step, thought, action, history)
519
575
  final_answer = action_input.to_s
520
576
 
@@ -523,11 +579,14 @@ module DSPy
523
579
  final_answer = last_observation
524
580
  end
525
581
 
582
+ # Convert action enum to string for storage in history
583
+ action_str = action.respond_to?(:serialize) ? action.serialize : action.to_s
584
+
526
585
  # Always add the finish action to history
527
586
  history << HistoryEntry.new(
528
587
  step: step,
529
588
  thought: thought,
530
- action: action,
589
+ action: action_str,
531
590
  action_input: final_answer,
532
591
  observation: nil # No observation for finish action
533
592
  )
@@ -207,6 +207,12 @@ module DSPy
207
207
  { type: "number" }
208
208
  elsif type == Numeric
209
209
  { type: "number" }
210
+ elsif type == Date
211
+ { type: "string", format: "date" }
212
+ elsif type == DateTime
213
+ { type: "string", format: "date-time" }
214
+ elsif type == Time
215
+ { type: "string", format: "date-time" }
210
216
  elsif [TrueClass, FalseClass].include?(type)
211
217
  { type: "boolean" }
212
218
  elsif type < T::Struct
@@ -225,6 +231,12 @@ module DSPy
225
231
  { type: "number" }
226
232
  when "Numeric"
227
233
  { type: "number" }
234
+ when "Date"
235
+ { type: "string", format: "date" }
236
+ when "DateTime"
237
+ { type: "string", format: "date-time" }
238
+ when "Time"
239
+ { type: "string", format: "date-time" }
228
240
  when "TrueClass", "FalseClass"
229
241
  { type: "boolean" }
230
242
  when "T::Boolean"
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'sorbet-runtime'
4
4
  require 'json'
5
+ require_relative '../type_system/sorbet_json_schema'
6
+ require_relative '../mixins/type_coercion'
5
7
 
6
8
  module DSPy
7
9
  module Tools
@@ -9,6 +11,7 @@ module DSPy
9
11
  class Base
10
12
  extend T::Sig
11
13
  extend T::Helpers
14
+ include DSPy::Mixins::TypeCoercion
12
15
 
13
16
  class << self
14
17
  extend T::Sig
@@ -30,14 +33,14 @@ module DSPy
30
33
 
31
34
  # Get the JSON schema for the call method based on its Sorbet signature
32
35
  sig { returns(T::Hash[Symbol, T.untyped]) }
33
- def call_schema
36
+ def call_schema_object
34
37
  method_obj = instance_method(:call)
35
38
  sig_info = T::Utils.signature_for_method(method_obj)
36
39
 
37
40
  if sig_info.nil?
38
41
  # Fallback for methods without signatures
39
42
  return {
40
- type: :object,
43
+ type: "object",
41
44
  properties: {},
42
45
  required: []
43
46
  }
@@ -50,10 +53,8 @@ module DSPy
50
53
  sig_info.arg_types.each do |param_name, param_type|
51
54
  next if param_name == :block # Skip block parameters
52
55
 
53
- properties[param_name] = {
54
- type: sorbet_type_to_json_schema(param_type)[:type],
55
- description: "Parameter #{param_name}"
56
- }
56
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
57
+ properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
57
58
 
58
59
  # Check if parameter is required (not nilable)
59
60
  unless param_type.class.name.include?('Union') && param_type.name.include?('NilClass')
@@ -65,10 +66,8 @@ module DSPy
65
66
  sig_info.kwarg_types.each do |param_name, param_type|
66
67
  next if param_name == :block # Skip block parameters
67
68
 
68
- properties[param_name] = {
69
- type: sorbet_type_to_json_schema(param_type)[:type],
70
- description: "Parameter #{param_name}"
71
- }
69
+ schema = DSPy::TypeSystem::SorbetJsonSchema.type_to_json_schema(param_type)
70
+ properties[param_name] = schema.merge({ description: "Parameter #{param_name}" })
72
71
 
73
72
  # Check if parameter is required by looking at required kwarg names
74
73
  if sig_info.req_kwarg_names.include?(param_name)
@@ -79,54 +78,25 @@ module DSPy
79
78
  end
80
79
 
81
80
  {
82
- type: :object,
81
+ type: "object",
83
82
  properties: properties,
84
83
  required: required
85
84
  }
86
85
  end
87
86
 
88
- private
89
-
90
- # Convert Sorbet types to JSON Schema types
91
- sig { params(sorbet_type: T.untyped).returns(T::Hash[Symbol, T.untyped]) }
92
- def sorbet_type_to_json_schema(sorbet_type)
93
- if sorbet_type.is_a?(T::Types::Simple)
94
- raw_type = sorbet_type.raw_type
95
-
96
- if raw_type == String
97
- { type: :string }
98
- elsif raw_type == Integer
99
- { type: :integer }
100
- elsif raw_type == Float
101
- { type: :number }
102
- elsif raw_type == Numeric
103
- { type: :number }
104
- elsif raw_type == TrueClass || raw_type == FalseClass
105
- { type: :boolean }
106
- elsif raw_type == T::Boolean
107
- { type: :boolean }
108
- else
109
- { type: :string, description: "#{raw_type} (converted to string)" }
110
- end
111
- elsif sorbet_type.is_a?(T::Types::Union)
112
- # Handle nilable types
113
- non_nil_types = sorbet_type.types.reject { |t| t == T::Utils.coerce(NilClass) }
114
- if non_nil_types.length == 1
115
- result = sorbet_type_to_json_schema(non_nil_types.first)
116
- result[:description] = "#{result[:description] || ''} (optional)".strip
117
- result
118
- else
119
- { type: :string, description: "Union type (converted to string)" }
120
- end
121
- elsif sorbet_type.is_a?(T::Types::TypedArray)
122
- {
123
- type: :array,
124
- items: sorbet_type_to_json_schema(sorbet_type.type)
87
+ # Get the full tool schema for LLM tools format
88
+ sig { returns(T::Hash[Symbol, T.untyped]) }
89
+ def call_schema
90
+ {
91
+ type: 'function',
92
+ function: {
93
+ name: 'call',
94
+ description: "Call the #{self.name} tool",
95
+ parameters: call_schema_object
125
96
  }
126
- else
127
- { type: :string, description: "#{sorbet_type} (converted to string)" }
128
- end
97
+ }
129
98
  end
99
+
130
100
  end
131
101
 
132
102
  # Instance methods that tools can use
@@ -143,7 +113,7 @@ module DSPy
143
113
  # Get the JSON schema string for the tool, formatted for LLM consumption
144
114
  sig { returns(String) }
145
115
  def schema
146
- schema_obj = self.class.call_schema
116
+ schema_obj = self.class.call_schema_object
147
117
  tool_info = {
148
118
  name: name,
149
119
  description: description,
@@ -152,11 +122,17 @@ module DSPy
152
122
  JSON.generate(tool_info)
153
123
  end
154
124
 
125
+ # Get the full call schema compatible with LLM tools format
126
+ sig { returns(T::Hash[Symbol, T.untyped]) }
127
+ def call_schema
128
+ self.class.call_schema
129
+ end
130
+
155
131
  # Dynamic call method for ReAct agent - parses JSON arguments and calls the typed method
156
132
  sig { params(args_json: T.untyped).returns(T.untyped) }
157
133
  def dynamic_call(args_json)
158
134
  # Parse arguments based on the call schema
159
- schema = self.class.call_schema
135
+ schema = self.class.call_schema_object
160
136
 
161
137
  if schema[:properties].empty?
162
138
  # No parameters - call without arguments
@@ -178,12 +154,34 @@ module DSPy
178
154
 
179
155
  # Convert string keys to symbols and validate types
180
156
  kwargs = {}
181
- schema[:properties].each do |param_name, param_schema|
182
- key = param_name.to_s
183
- if args.key?(key)
184
- kwargs[param_name] = convert_argument_type(args[key], param_schema)
185
- elsif schema[:required].include?(key)
186
- return "Error: Missing required parameter: #{key}"
157
+
158
+ # Get method signature for type information
159
+ method_obj = self.class.instance_method(:call)
160
+ sig_info = T::Utils.signature_for_method(method_obj)
161
+
162
+ if sig_info
163
+ # Handle kwargs using type signature information
164
+ sig_info.kwarg_types.each do |param_name, param_type|
165
+ next if param_name == :block
166
+
167
+ key = param_name.to_s
168
+ if args.key?(key)
169
+ kwargs[param_name] = coerce_value_to_type(args[key], param_type)
170
+ elsif schema[:required].include?(key)
171
+ return "Error: Missing required parameter: #{key}"
172
+ end
173
+ end
174
+
175
+ # Handle positional args if any
176
+ sig_info.arg_types.each do |param_name, param_type|
177
+ next if param_name == :block
178
+
179
+ key = param_name.to_s
180
+ if args.key?(key)
181
+ kwargs[param_name] = coerce_value_to_type(args[key], param_type)
182
+ elsif schema[:required].include?(key)
183
+ return "Error: Missing required parameter: #{key}"
184
+ end
187
185
  end
188
186
  end
189
187
 
@@ -195,32 +193,6 @@ module DSPy
195
193
 
196
194
  # Subclasses must implement their own call method with their own signature
197
195
 
198
- protected
199
-
200
- # Convert argument to the expected type based on JSON schema
201
- sig { params(value: T.untyped, schema: T::Hash[Symbol, T.untyped]).returns(T.untyped) }
202
- def convert_argument_type(value, schema)
203
- case schema[:type]
204
- when :integer
205
- value.is_a?(Integer) ? value : value.to_i
206
- when :number
207
- # Always convert to Float for :number types to ensure compatibility with strict Float signatures
208
- value.to_f
209
- when :boolean
210
- case value
211
- when true, false
212
- value
213
- when "true", "1", 1
214
- true
215
- when "false", "0", 0
216
- false
217
- else
218
- !!value
219
- end
220
- else
221
- value.to_s
222
- end
223
- end
224
196
  end
225
197
  end
226
198
  end