stealth 1.1.3 → 2.0.0.beta2
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/.circleci/config.yml +15 -54
- data/CHANGELOG.md +93 -0
- data/Gemfile +1 -1
- data/Gemfile.lock +53 -48
- data/LICENSE +4 -17
- data/README.md +9 -17
- data/VERSION +1 -1
- data/lib/stealth/base.rb +72 -19
- data/lib/stealth/cli.rb +1 -2
- data/lib/stealth/commands/console.rb +1 -1
- data/lib/stealth/configuration.rb +6 -3
- data/lib/stealth/controller/callbacks.rb +1 -1
- data/lib/stealth/controller/catch_all.rb +27 -4
- data/lib/stealth/controller/controller.rb +168 -49
- data/lib/stealth/controller/dev_jumps.rb +41 -0
- data/lib/stealth/controller/dynamic_delay.rb +4 -6
- data/lib/stealth/controller/interrupt_detect.rb +100 -0
- data/lib/stealth/controller/messages.rb +283 -0
- data/lib/stealth/controller/nlp.rb +50 -0
- data/lib/stealth/controller/replies.rb +183 -41
- data/lib/stealth/controller/unrecognized_message.rb +62 -0
- data/lib/stealth/core_ext.rb +5 -0
- data/lib/stealth/{flow/core_ext.rb → core_ext/numeric.rb} +0 -1
- data/lib/stealth/core_ext/string.rb +18 -0
- data/lib/stealth/dispatcher.rb +21 -0
- data/lib/stealth/errors.rb +12 -0
- data/lib/stealth/flow/base.rb +1 -2
- data/lib/stealth/flow/specification.rb +3 -2
- data/lib/stealth/flow/state.rb +3 -3
- data/lib/stealth/generators/builder/Gemfile +4 -3
- data/lib/stealth/generators/builder/bot/controllers/bot_controller.rb +42 -0
- data/lib/stealth/generators/builder/bot/controllers/catch_alls_controller.rb +2 -0
- data/lib/stealth/generators/builder/bot/controllers/goodbyes_controller.rb +2 -0
- data/lib/stealth/generators/builder/bot/controllers/hellos_controller.rb +2 -0
- data/lib/stealth/generators/builder/bot/controllers/interrupts_controller.rb +9 -0
- data/lib/stealth/generators/builder/bot/controllers/unrecognized_messages_controller.rb +9 -0
- data/lib/stealth/generators/builder/config/flow_map.rb +8 -0
- data/lib/stealth/generators/builder/config/initializers/autoload.rb +8 -0
- data/lib/stealth/generators/builder/config/initializers/inflections.rb +16 -0
- data/lib/stealth/generators/builder/config/puma.rb +15 -0
- data/lib/stealth/helpers/redis.rb +40 -0
- data/lib/stealth/lock.rb +83 -0
- data/lib/stealth/logger.rb +27 -18
- data/lib/stealth/nlp/client.rb +22 -0
- data/lib/stealth/nlp/result.rb +57 -0
- data/lib/stealth/reloader.rb +90 -0
- data/lib/stealth/reply.rb +17 -0
- data/lib/stealth/scheduled_reply.rb +3 -3
- data/lib/stealth/server.rb +3 -3
- data/lib/stealth/service_message.rb +3 -2
- data/lib/stealth/service_reply.rb +5 -1
- data/lib/stealth/services/base_reply_handler.rb +10 -2
- data/lib/stealth/session.rb +106 -53
- data/spec/configuration_spec.rb +42 -2
- data/spec/controller/callbacks_spec.rb +23 -28
- data/spec/controller/catch_all_spec.rb +81 -29
- data/spec/controller/controller_spec.rb +444 -43
- data/spec/controller/dynamic_delay_spec.rb +16 -18
- data/spec/controller/helpers_spec.rb +1 -2
- data/spec/controller/interrupt_detect_spec.rb +171 -0
- data/spec/controller/messages_spec.rb +744 -0
- data/spec/controller/nlp_spec.rb +93 -0
- data/spec/controller/replies_spec.rb +446 -11
- data/spec/controller/unrecognized_message_spec.rb +168 -0
- data/spec/dispatcher_spec.rb +79 -0
- data/spec/flow/flow_spec.rb +1 -2
- data/spec/flow/state_spec.rb +14 -3
- data/spec/helpers/redis_spec.rb +77 -0
- data/spec/lock_spec.rb +100 -0
- data/spec/nlp/client_spec.rb +23 -0
- data/spec/nlp/result_spec.rb +57 -0
- data/spec/replies/messages/say_msgs_without_breaks.yml +4 -0
- data/spec/replies/messages/say_randomize_speech.yml +10 -0
- data/spec/replies/messages/say_randomize_text.yml +10 -0
- data/spec/replies/messages/sub1/sub2/say_nested.yml +10 -0
- data/spec/reply_spec.rb +61 -0
- data/spec/scheduled_reply_spec.rb +23 -0
- data/spec/service_reply_spec.rb +1 -2
- data/spec/session_spec.rb +251 -12
- data/spec/spec_helper.rb +21 -0
- data/spec/support/controllers/vaders_controller.rb +24 -0
- data/spec/support/nlp_clients/dialogflow.rb +9 -0
- data/spec/support/nlp_clients/luis.rb +9 -0
- data/spec/support/nlp_results/luis_result.rb +163 -0
- data/spec/version_spec.rb +1 -2
- data/stealth.gemspec +6 -6
- metadata +83 -38
- data/docs/00-introduction.md +0 -37
- data/docs/01-getting-started.md +0 -21
- data/docs/02-local-development.md +0 -40
- data/docs/03-basics.md +0 -171
- data/docs/04-sessions.md +0 -29
- data/docs/05-controllers.md +0 -179
- data/docs/06-models.md +0 -39
- data/docs/07-replies.md +0 -114
- data/docs/08-catchalls.md +0 -49
- data/docs/09-messaging-integrations.md +0 -80
- data/docs/10-nlp-integrations.md +0 -13
- data/docs/11-analytics.md +0 -13
- data/docs/12-commands.md +0 -62
- data/docs/13-deployment.md +0 -50
- data/lib/stealth/generators/builder/config/initializers/.keep +0 -0
@@ -12,17 +12,15 @@ module Stealth
|
|
12
12
|
LONG_DELAY = 7.0
|
13
13
|
|
14
14
|
included do
|
15
|
-
def dynamic_delay(
|
16
|
-
|
17
|
-
calculate_delay(previous_reply: {})
|
18
|
-
else
|
19
|
-
calculate_delay(previous_reply: service_replies[position - 1])
|
20
|
-
end
|
15
|
+
def dynamic_delay(previous_reply:)
|
16
|
+
calculate_delay(previous_reply: previous_reply)
|
21
17
|
end
|
22
18
|
|
23
19
|
private
|
24
20
|
|
25
21
|
def calculate_delay(previous_reply:)
|
22
|
+
return SHORT_DELAY if previous_reply.blank?
|
23
|
+
|
26
24
|
case previous_reply['reply_type']
|
27
25
|
when 'text'
|
28
26
|
calculate_delay_from_text(previous_reply['text'])
|
@@ -0,0 +1,100 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
class Controller
|
6
|
+
module InterruptDetect
|
7
|
+
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
include Stealth::Redis
|
11
|
+
|
12
|
+
included do
|
13
|
+
|
14
|
+
attr_reader :current_lock
|
15
|
+
|
16
|
+
def current_lock
|
17
|
+
@current_lock ||= Stealth::Lock.find_lock(
|
18
|
+
session_id: current_session_id
|
19
|
+
)
|
20
|
+
end
|
21
|
+
|
22
|
+
def run_interrupt_action
|
23
|
+
Stealth::Logger.l(
|
24
|
+
topic: 'interrupt',
|
25
|
+
message: "Interrupt detected for session #{current_session_id}"
|
26
|
+
)
|
27
|
+
|
28
|
+
unless defined?(InterruptsController)
|
29
|
+
Stealth::Logger.l(
|
30
|
+
topic: 'interrupt',
|
31
|
+
message: 'Ignoring interrupt; InterruptsController not defined.'
|
32
|
+
)
|
33
|
+
|
34
|
+
return false
|
35
|
+
end
|
36
|
+
|
37
|
+
interrupt_controller = InterruptsController.new(
|
38
|
+
service_message: current_message
|
39
|
+
)
|
40
|
+
|
41
|
+
begin
|
42
|
+
# Run say_interrupted action
|
43
|
+
interrupt_controller.say_interrupted
|
44
|
+
|
45
|
+
unless interrupt_controller.progressed?
|
46
|
+
# Log, but we cannot run the catch_all here
|
47
|
+
Stealth::Logger.l(
|
48
|
+
topic: 'interrupt',
|
49
|
+
message: 'Did not send replies, update session, or step'
|
50
|
+
)
|
51
|
+
end
|
52
|
+
rescue StandardError => e
|
53
|
+
# Log, but we cannot run the catch_all here
|
54
|
+
Stealth::Logger.l(
|
55
|
+
topic: 'interrupt',
|
56
|
+
message: [e.message, e.backtrace.join("\n")].join("\n")
|
57
|
+
)
|
58
|
+
end
|
59
|
+
end
|
60
|
+
|
61
|
+
private
|
62
|
+
|
63
|
+
def interrupt_detected?
|
64
|
+
# No interruption if there isn't an existing lock for this session
|
65
|
+
return false if current_lock.blank?
|
66
|
+
|
67
|
+
# No interruption if we are in the same thread
|
68
|
+
return false if current_thread_has_control?
|
69
|
+
|
70
|
+
true
|
71
|
+
end
|
72
|
+
|
73
|
+
def current_thread_has_control?
|
74
|
+
current_lock.tid == Stealth.tid
|
75
|
+
end
|
76
|
+
|
77
|
+
def lock_session!(session_slug:, position: nil)
|
78
|
+
lock = Stealth::Lock.new(
|
79
|
+
session_id: current_session_id,
|
80
|
+
session_slug: session_slug,
|
81
|
+
position: position
|
82
|
+
)
|
83
|
+
|
84
|
+
lock.create
|
85
|
+
end
|
86
|
+
|
87
|
+
# Yields control to other threads to take action on this session
|
88
|
+
# by releasing the lock.
|
89
|
+
def release_lock!
|
90
|
+
# We don't want to release the lock from within InterruptsController
|
91
|
+
# otherwise the InterruptsController can get interrupted.
|
92
|
+
unless self.class.to_s == 'InterruptsController'
|
93
|
+
current_lock&.release
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|
97
|
+
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
@@ -0,0 +1,283 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module Stealth
|
4
|
+
class Controller
|
5
|
+
module Messages
|
6
|
+
extend ActiveSupport::Concern
|
7
|
+
|
8
|
+
included do
|
9
|
+
attr_accessor :normalized_msg, :homophone_translated_msg
|
10
|
+
|
11
|
+
unless defined?(ALPHA_ORDINALS)
|
12
|
+
ALPHA_ORDINALS = ('A'..'Z').to_a.freeze
|
13
|
+
end
|
14
|
+
|
15
|
+
unless defined?(NO_MATCH)
|
16
|
+
NO_MATCH = 0xdeadbeef
|
17
|
+
end
|
18
|
+
|
19
|
+
unless defined?(HOMOPHONES)
|
20
|
+
HOMOPHONES = {
|
21
|
+
'EH' => 'A',
|
22
|
+
'BE' => 'B',
|
23
|
+
'BEE' => 'B',
|
24
|
+
'CEE' => 'C',
|
25
|
+
'SEA' => 'C',
|
26
|
+
'SEE' => 'C',
|
27
|
+
'DEE' => 'D',
|
28
|
+
'GEE' => 'G',
|
29
|
+
'EYE' => 'I',
|
30
|
+
'AYE' => 'I',
|
31
|
+
'JAY' => 'J',
|
32
|
+
'KAY' => 'K',
|
33
|
+
'KAYE' => 'K',
|
34
|
+
'OH' => 'O',
|
35
|
+
'OWE' => 'O',
|
36
|
+
'PEA' => 'P',
|
37
|
+
'PEE' => 'P',
|
38
|
+
'CUE' => 'Q',
|
39
|
+
'QUEUE' => 'Q',
|
40
|
+
'ARR' => 'R',
|
41
|
+
'YOU' => 'U',
|
42
|
+
'YEW' => 'U',
|
43
|
+
'EX' => 'X',
|
44
|
+
'WHY' => 'Y',
|
45
|
+
'ZEE' => 'Z'
|
46
|
+
}
|
47
|
+
end
|
48
|
+
|
49
|
+
def normalized_msg
|
50
|
+
@normalized_msg ||= current_message.message.normalize
|
51
|
+
end
|
52
|
+
|
53
|
+
# Converts homophones into alpha-ordinals
|
54
|
+
def homophone_translated_msg
|
55
|
+
@homophone_translated_msg ||= begin
|
56
|
+
ord = normalized_msg.without_punctuation
|
57
|
+
if HOMOPHONES[ord].present?
|
58
|
+
HOMOPHONES[ord]
|
59
|
+
else
|
60
|
+
ord
|
61
|
+
end
|
62
|
+
end
|
63
|
+
end
|
64
|
+
|
65
|
+
# Hash for message and lambda pairs. If the message is matched, the
|
66
|
+
# lambda will be called.
|
67
|
+
#
|
68
|
+
# Example: {
|
69
|
+
# "100k" => proc { step_back }, "200k" => proc { step_to flow :hello }
|
70
|
+
# }
|
71
|
+
def handle_message(message_tuples)
|
72
|
+
match = NO_MATCH # dummy value since nils are used for matching
|
73
|
+
|
74
|
+
if reserved_homophones_used = contains_homophones?(message_tuples.keys)
|
75
|
+
raise(
|
76
|
+
Stealth::Errors::ReservedHomophoneUsed,
|
77
|
+
"Cannot use `#{reserved_homophones_used.join(', ')}`. Reserved for homophones."
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Before checking content, match against our ordinals
|
82
|
+
if idx = message_is_an_ordinal?
|
83
|
+
# find the value stored in the message tuple via the index
|
84
|
+
matched_value = message_tuples.keys[idx]
|
85
|
+
match = matched_value unless matched_value.nil?
|
86
|
+
end
|
87
|
+
|
88
|
+
if match == NO_MATCH
|
89
|
+
message_tuples.keys.each_with_index do |msg, i|
|
90
|
+
# intent detection
|
91
|
+
if msg.is_a?(Symbol)
|
92
|
+
perform_nlp! unless nlp_result.present?
|
93
|
+
|
94
|
+
if intent_matched?(msg)
|
95
|
+
match = msg
|
96
|
+
break
|
97
|
+
else
|
98
|
+
next
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
if msg.is_a?(Regexp)
|
103
|
+
if normalized_msg =~ msg
|
104
|
+
match = msg
|
105
|
+
break
|
106
|
+
else
|
107
|
+
next
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
# custom mismatch handler; any nil key results in a match
|
112
|
+
if msg.nil?
|
113
|
+
match = msg
|
114
|
+
break
|
115
|
+
end
|
116
|
+
|
117
|
+
# check if the normalized message matches exactly
|
118
|
+
if message_matches?(msg)
|
119
|
+
match = msg
|
120
|
+
break
|
121
|
+
end
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
if match != NO_MATCH
|
126
|
+
instance_eval(&message_tuples[match])
|
127
|
+
else
|
128
|
+
handle_mismatch(true)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# Matches the message or the oridinal value entered (via SMS)
|
133
|
+
# Ignores case and strips leading and trailing whitespace before matching.
|
134
|
+
def get_match(messages, raise_on_mismatch: true, fuzzy_match: true)
|
135
|
+
if reserved_homophones_used = contains_homophones?(messages)
|
136
|
+
raise(
|
137
|
+
Stealth::Errors::ReservedHomophoneUsed,
|
138
|
+
"Cannot use `#{reserved_homophones_used.join(', ')}`. Reserved for homophones."
|
139
|
+
)
|
140
|
+
end
|
141
|
+
|
142
|
+
# Before checking content, match against our ordinals
|
143
|
+
if idx = message_is_an_ordinal?
|
144
|
+
return messages[idx] unless messages[idx].nil?
|
145
|
+
end
|
146
|
+
|
147
|
+
messages.each_with_index do |msg, i|
|
148
|
+
# entity detection
|
149
|
+
if msg.is_a?(Symbol)
|
150
|
+
perform_nlp! unless nlp_result.present?
|
151
|
+
|
152
|
+
if match = entity_matched?(msg, fuzzy_match)
|
153
|
+
return match
|
154
|
+
else
|
155
|
+
next
|
156
|
+
end
|
157
|
+
end
|
158
|
+
|
159
|
+
# multi-entity detection
|
160
|
+
if msg.is_a?(Array)
|
161
|
+
perform_nlp! unless nlp_result.present?
|
162
|
+
|
163
|
+
if match = entities_matched?(msg, fuzzy_match)
|
164
|
+
return match
|
165
|
+
else
|
166
|
+
next
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
if message_matches?(msg)
|
171
|
+
return msg
|
172
|
+
end
|
173
|
+
end
|
174
|
+
|
175
|
+
handle_mismatch(raise_on_mismatch)
|
176
|
+
end
|
177
|
+
|
178
|
+
private
|
179
|
+
|
180
|
+
def handle_mismatch(raise_on_mismatch)
|
181
|
+
log_nlp_result unless Stealth.config.log_all_nlp_results # Already logged
|
182
|
+
|
183
|
+
if raise_on_mismatch
|
184
|
+
raise(
|
185
|
+
Stealth::Errors::UnrecognizedMessage,
|
186
|
+
"The reply '#{current_message.message}' was not recognized."
|
187
|
+
)
|
188
|
+
else
|
189
|
+
current_message.message
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def contains_homophones?(arr)
|
194
|
+
arr = arr.map do |elem|
|
195
|
+
elem.normalize if elem.is_a?(String)
|
196
|
+
end.compact
|
197
|
+
|
198
|
+
homophones = arr & HOMOPHONES.keys
|
199
|
+
homophones.any? ? homophones : false
|
200
|
+
end
|
201
|
+
|
202
|
+
# Returns the index of the ordinal, nil if not found
|
203
|
+
def message_is_an_ordinal?
|
204
|
+
ALPHA_ORDINALS.index(homophone_translated_msg)
|
205
|
+
end
|
206
|
+
|
207
|
+
def message_matches?(msg)
|
208
|
+
normalized_msg == msg.upcase
|
209
|
+
end
|
210
|
+
|
211
|
+
def intent_matched?(intent)
|
212
|
+
nlp_result.intent == intent
|
213
|
+
end
|
214
|
+
|
215
|
+
def entity_matched?(entity, fuzzy_match)
|
216
|
+
if nlp_result.entities.has_key?(entity)
|
217
|
+
match_count = nlp_result.entities[entity].size
|
218
|
+
if match_count > 1 && !fuzzy_match
|
219
|
+
log_nlp_result unless Stealth.config.log_all_nlp_results # Already logged
|
220
|
+
|
221
|
+
raise(
|
222
|
+
Stealth::Errors::UnrecognizedMessage,
|
223
|
+
"Encountered #{match_count} entity matches of type #{entity.inspect} and expected 1. To allow, set fuzzy_match to true."
|
224
|
+
)
|
225
|
+
else
|
226
|
+
# For single entity matches, just return the value
|
227
|
+
# rather than a single-element array
|
228
|
+
matched_entity = nlp_result.entities[entity].first
|
229
|
+
|
230
|
+
# Custom LUIS List entities return a single element array for some
|
231
|
+
# reason
|
232
|
+
if matched_entity.is_a?(Array) && matched_entity.size == 1
|
233
|
+
matched_entity.first
|
234
|
+
else
|
235
|
+
matched_entity
|
236
|
+
end
|
237
|
+
end
|
238
|
+
end
|
239
|
+
end
|
240
|
+
|
241
|
+
def entities_matched?(entities, fuzzy_match)
|
242
|
+
nlp_entities = nlp_result.entities.deep_dup
|
243
|
+
results = []
|
244
|
+
|
245
|
+
entities.each do |entity|
|
246
|
+
# If we run out of matches for the entity type
|
247
|
+
# (or never had any to begin with)
|
248
|
+
return false if nlp_entities[entity].blank?
|
249
|
+
|
250
|
+
results << nlp_entities[entity].shift
|
251
|
+
end
|
252
|
+
|
253
|
+
# Check for leftover entities for the types we were looking for
|
254
|
+
unless fuzzy_match
|
255
|
+
entities.each do |entity|
|
256
|
+
unless nlp_entities[entity].blank?
|
257
|
+
log_nlp_result unless Stealth.config.log_all_nlp_results # Already logged
|
258
|
+
leftover_count = nlp_entities[entity].size
|
259
|
+
raise(
|
260
|
+
Stealth::Errors::UnrecognizedMessage,
|
261
|
+
"Encountered #{leftover_count} additional entity matches of type #{entity.inspect} for match #{entities.inspect}. To allow, set fuzzy_match to true."
|
262
|
+
)
|
263
|
+
end
|
264
|
+
end
|
265
|
+
end
|
266
|
+
|
267
|
+
results
|
268
|
+
end
|
269
|
+
|
270
|
+
def log_nlp_result
|
271
|
+
# Log the results from the nlp_result if NLP was performed
|
272
|
+
if nlp_result.present?
|
273
|
+
Stealth::Logger.l(
|
274
|
+
topic: :nlp,
|
275
|
+
message: "User #{current_session_id} -> NLP Result: #{nlp_result.parsed_result.inspect}"
|
276
|
+
)
|
277
|
+
end
|
278
|
+
end
|
279
|
+
|
280
|
+
end
|
281
|
+
end
|
282
|
+
end
|
283
|
+
end
|
@@ -0,0 +1,50 @@
|
|
1
|
+
# coding: utf-8
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
module Stealth
|
5
|
+
class Controller
|
6
|
+
module Nlp
|
7
|
+
|
8
|
+
extend ActiveSupport::Concern
|
9
|
+
|
10
|
+
included do
|
11
|
+
# Memoized in order to prevent multiple requests to the NLP provider
|
12
|
+
def perform_nlp!
|
13
|
+
Stealth::Logger.l(
|
14
|
+
topic: :nlp,
|
15
|
+
message: "User #{current_session_id} -> Performing NLP."
|
16
|
+
)
|
17
|
+
|
18
|
+
unless Stealth.config.nlp_integration.present?
|
19
|
+
raise Stealth::Errors::ConfigurationError, "An NLP integration has not yet been configured (Stealth.config.nlp_integration)"
|
20
|
+
end
|
21
|
+
|
22
|
+
@nlp_result ||= begin
|
23
|
+
nlp_client = nlp_client_klass.new
|
24
|
+
@nlp_result = @current_message.nlp_result = nlp_client.understand(
|
25
|
+
query: current_message.message
|
26
|
+
)
|
27
|
+
|
28
|
+
if Stealth.config.log_all_nlp_results
|
29
|
+
Stealth::Logger.l(
|
30
|
+
topic: :nlp,
|
31
|
+
message: "User #{current_session_id} -> NLP Result: #{@nlp_result.parsed_result.inspect}"
|
32
|
+
)
|
33
|
+
end
|
34
|
+
|
35
|
+
@nlp_result
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
def nlp_client_klass
|
42
|
+
integration = Stealth.config.nlp_integration.to_s.titlecase
|
43
|
+
klass = "Stealth::Nlp::#{integration}::Client"
|
44
|
+
klass.classify.constantize
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
end
|
49
|
+
end
|
50
|
+
end
|