aws-lex-conversation 5.1.1 → 6.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 46556f575fd08ac0ded8d8fc50692be70388fbf1e3d96ff8d236c82a5e8eb860
4
- data.tar.gz: c5456ec5961936b210d80491bb9edaebf503c71daba5acf755257b36ae211875
3
+ metadata.gz: 3a4ab092d321df0c71e5f16cf1021e87358b1b39ab025eaf5555e3e3da7894c1
4
+ data.tar.gz: a0332c4f2f73b35972c83a0740e1adfa4d724fe5d9861087d348e905e3e15633
5
5
  SHA512:
6
- metadata.gz: dfcf7591d2c9667d17f3863dde180724c4dc5ec5b2f623173be6c11b6cbf9548d99b39ae1ad809e25adb6069fea9239dc58c3a8776bc85340ec07c9752a3c976
7
- data.tar.gz: 6cd97d779b1e482c19300db5cb63e9d36f01278be04c896dc603138b0a293d0baf8fa8cfadfd95d27896b4a467d81211607bb0cb551e960f72d3efd740a5a9cf
6
+ metadata.gz: 734b1ec8921c8982a3e123520b08b0d0b9e655159c781f7df18d106b8de635815bc026862fd6a4ec7a3d2665d47393bb8d977cf530a1331e2f3c2bea543b4a94
7
+ data.tar.gz: 2c4c43bafd522791d6f5f39596c7b594d3788098876b51fc8294cf2304a23c42a06fd6635e15e791410bc824273599f9afab3935fd853981f81b732672d03d96
data/.rubocop.yml CHANGED
@@ -20,12 +20,17 @@ Metrics/AbcSize:
20
20
  Max: 20
21
21
  Metrics/ClassLength:
22
22
  Max: 150
23
+ Exclude:
24
+ - lib/aws/lex/conversation/simulator.rb
23
25
  Metrics/BlockLength:
24
26
  Exclude:
25
27
  - 'spec/**/*'
26
28
  - '*.gemspec'
27
29
  Metrics/MethodLength:
28
30
  Max: 25
31
+ Metrics/ModuleLength:
32
+ Exclude:
33
+ - lib/aws/lex/conversation/spec/matchers.rb
29
34
  Naming/FileName:
30
35
  Enabled: true
31
36
  Exclude:
data/CHANGELOG.md CHANGED
@@ -1,3 +1,107 @@
1
+ # 6.2.0 - Sept 28, 2021
2
+
3
+ * Add a `Aws::Lex::Conversation#restore_from!` method that accepts a checkpoint parameter. This method modifies the underlying conversation state to match the data from the saved checkpoint.
4
+ * Make the `dialog_action_type` parameter on `Aws::Lex::Conversation#checkpoint!` default to `Delegate` if not specified as a developer convenience.
5
+ * Allow developers to pass an optional `intent` override parameter on `Aws::Lex::Conversation#checkpoint!` for convenience.
6
+ * Update the README with advanced examples for the conversation stash and checkpoints.
7
+
8
+ # 6.1.1 - Sept 22, 2021
9
+
10
+ * renamed `maximum_elicitations` to `max_retries` and made it backwards compatible to make the param name clear, by default this value is zero, allowing each slot to elicit only once
11
+
12
+ # 6.1.0 - Sept 7, 2021
13
+
14
+ Added helper methods for clearing active contexts
15
+
16
+ ```ruby
17
+ conversation.clear_context!(name: 'test') # clears this specific active context
18
+ conversation.clear_all_contexts! # clears all current active contexts
19
+ ```
20
+
21
+ # 6.0.0 - Sept 7, 2021
22
+
23
+ * **breaking change** - Modify `Aws::Lex::Conversation::Type::Base#computed_property` to accept a block instead of a callable argument. This is an internal class and should not require any application-level changes.
24
+ * **breaking change** - Add a required `alias_name` attribute on `Aws::Lex::Conversation::Type::Bot` instances. Please note that the Version 2 of AWS Lex correctly returns this value as input to lambda functions. Therefore no application-level changes are necessary.
25
+ * Implement a new set of test helpers to make it easier to modify state and match test expectations. You can use the test helpers as follows:
26
+
27
+ ```ruby
28
+ # we must explicitly require the test helpers
29
+ require 'aws/lex/conversation/spec'
30
+
31
+ # optional: include the custom matchers if you're using RSpec
32
+ RSpec.configure do |config|
33
+ config.include(Aws::Lex::Conversation::Spec)
34
+ end
35
+
36
+ # we can now simulate state in a test somewhere
37
+ it 'simulates a conversation' do
38
+ conversation # given we have an instance of Aws::Lex::Conversation
39
+ .simulate! # simulation modifies the underlying instance
40
+ .transcript('My age is 21') # optionally set an input transcript
41
+ .intent(name: 'MyCoolIntent') # route to the intent named "MyCoolIntent"
42
+ .slot(name: 'Age', value: '21') # add a slot named "Age" with a corresponding value
43
+
44
+ expect(conversation).to have_transcript('My age is 21')
45
+ expect(conversation).to route_to_intent('MyCoolIntent')
46
+ expect(conversation).to have_slot(name: 'Age', value: '21')
47
+ end
48
+
49
+ # if you'd rather create your own event from scratch
50
+ it 'creates an event' do
51
+ simulator = Aws::Lex::Conversation::Simulator.new
52
+ simulator
53
+ .transcript('I am 21 years old.')
54
+ .input_mode('Speech')
55
+ .context(name: 'WelcomeGreetingCompleted')
56
+ .invocation_source('FulfillmentCodeHook')
57
+ .session(username: 'jane.doe')
58
+ .intent(
59
+ name: 'GuessZodiacSign',
60
+ state: 'ReadyForFulfillment',
61
+ slots: {
62
+ age: {
63
+ value: '21'
64
+ }
65
+ }
66
+ )
67
+ event = simulator.event
68
+
69
+ expect(event).to have_transcript('I am 21 years old.')
70
+ expect(event).to have_input_mode('Speech')
71
+ expect(event).to have_active_context(name: 'WelcomeGreetingCompleted')
72
+ expect(event).to have_invocation_source('FulfillmentCodeHook')
73
+ expect(event).to route_to_intent('GuessZodiacSign')
74
+ expect(event).to have_slot(name: 'age', value: '21')
75
+ expect(event).to include_session_values(username: 'jane.doe')
76
+ end
77
+ ```
78
+
79
+ * Add a few convenience methods to `Aws::Lex::Conversation` instances for dealing with active contexts:
80
+ - `#active_context(name:)`:
81
+
82
+ Returns the active context instance that matches the name parameter.
83
+
84
+ - `#active_context?(name:)`:
85
+
86
+ Returns true/false depending on if an active context matching
87
+ the name parameter is found.
88
+
89
+ - `#active_context!(name:, turns:, seconds:, attributes:)`:
90
+
91
+ Creates or updates an existing active context instance for
92
+ the conversation.
93
+
94
+ # 5.1.0 - Sept 2, 2021
95
+
96
+ * Allow the intent to be specified when returning a response such as `elicit_slot`.
97
+
98
+ # 5.0.0 - August 30, 2021
99
+
100
+ * **breaking change** - `Aws::Lex::Conversation::Support::Mixins::SlotElicitation`
101
+ - Rename the `message` attribute to `messages`. This attribute must be a callable that returns and array of `Aws::Lex::Conversation::Type::Message` instances.
102
+ - rename the `follow_up_message` attribute to `follow_up_messages`. This must also be a callable that returns an array of message instances.
103
+ * Allow the `fallback` callable in `SlotElicitation` to be nilable. The slot value will not be elicited if the value is nil and maximum attempts have been exceeded.
104
+
1
105
  # 4.3.0 - August 25, 2021
2
106
 
3
107
  * Slot elicitor can now be passed an Aws::Lex::Conversation::Type::Message as part of the DSL/callback and properly formats the response as such
data/README.md CHANGED
@@ -191,6 +191,170 @@ conversation.handlers = [
191
191
  conversation.respond # => { dialogAction: { type: 'Delegate' } }
192
192
  ```
193
193
 
194
+ ## Advanced Concepts
195
+
196
+ This library provides a few constructs to help manage complex interactions:
197
+
198
+ ### Data Stash
199
+
200
+ `Aws::Lex::Conversation` instances implement a `stash` method that can be used to store temporary data within a single invocation.
201
+
202
+ A conversation's stashed data will not be persisted between multiple invocations of your lambda function.
203
+
204
+ The conversation stash is a great spot to store deserialized data from the session, or invocation-specific state that needs to be shared between handler classes.
205
+
206
+ This example illustrates how the stash can be used to store deserialized data from the session:
207
+
208
+ ```ruby
209
+ # given we have JSON-serialized data in as a persisted session value
210
+ conversation.session[:user_data] = '{"name":"Jane","id":1234,"email":"test@example.com"}'
211
+ # we can deserialize the data into a Hash that we store in the conversation stash
212
+ conversation.stash[:user] = JSON.parse(conversation.session[:user_data])
213
+ # later on we can reference our stashed data (within the same invocation)
214
+ conversation.stash[:user] # => {"name"=>"Jane", "id"=>1234, "email"=>"test@example.com"}
215
+ ```
216
+
217
+ ### Checkpoints
218
+
219
+ A conversation may transition between many different topics as the interaction progresses. This type of state transition can be easily handled with checkpoints.
220
+
221
+ When a checkpoint is created, all intent and slot data is encoded and stored into a `checkpoints` session value. This data persists between invocations, and is not removed until the checkpoint is restored.
222
+
223
+ You can create a checkpoint as follows:
224
+
225
+ ```ruby
226
+ # we're ready to fulfill the OrderFlowers intent, but we want to elicit another intent first
227
+ conversation.checkpoint!(
228
+ label: 'order_flowers',
229
+ dialog_action_type: 'Close' # defaults to 'Delegate' if not specified
230
+ )
231
+ conversation.elicit_intent(
232
+ messages: [
233
+ {
234
+ content: 'Thanks! Before I place your order, is there anything else I can help with?',
235
+ contentType: 'PlainText'
236
+ }
237
+ ]
238
+ )
239
+ ```
240
+
241
+ You can restore the checkpoint in one of two ways:
242
+
243
+ ```ruby
244
+ # in a future invocation, we can fetch an instance of the checkpoint and easily
245
+ # restore the conversation to the previous state
246
+ checkpoint = conversation.checkpoint(label: 'order_flowers')
247
+ checkpoint.restore!(
248
+ fulfillment_state: 'Fulfilled',
249
+ messages: [
250
+ {
251
+ content: 'Okay, your flowers have been ordered! Thanks!',
252
+ contentType: 'PlainText'
253
+ }
254
+ ]
255
+ ) # => our response object to Lex is returned
256
+ ```
257
+
258
+ It's also possible to restore state from a checkpoint and utilize the conversation's handler chain:
259
+
260
+ ```ruby
261
+ class AnotherIntent < Aws::Lex::Conversation::Handler::Base
262
+ def will_respond?(conversation)
263
+ conversation.intent_name == 'AnotherIntent' &&
264
+ conversation.checkpoint?(label: 'order_flowers')
265
+ end
266
+
267
+ def response(conversation)
268
+ checkpoint = conversation.checkpoint(label: 'order_flowers')
269
+ # replace the conversation's current resolved intent/slot data with the saved checkpoint data
270
+ conversation.restore_from!(checkpoint)
271
+ # call the next handler in the chain to produce a response
272
+ successor.handle(conversation)
273
+ end
274
+ end
275
+
276
+ class OrderFlowers < Aws::Lex::Conversation::Handler::Base
277
+ def will_respond?(conversation)
278
+ conversation.intent_name == 'OrderFlowers'
279
+ end
280
+
281
+ def response(conversation)
282
+ conversation.close(
283
+ fulfillment_state: 'Fulfilled',
284
+ messages: [
285
+ {
286
+ content: 'Okay, your flowers have been ordered! Thanks!',
287
+ contentType: 'PlainText'
288
+ }
289
+ ]
290
+ )
291
+ end
292
+ end
293
+
294
+ conversation = Aws::Lex::Conversation.new(event: event, context: context)
295
+ conversation.handlers = [
296
+ { handler: AnotherIntent },
297
+ { handler: OrderFlowers }
298
+ ]
299
+ conversation.respond # => returns a Lex response object
300
+ ```
301
+
302
+ ## Test Helpers
303
+
304
+ This library provides convenience methods to make testing easy! You can use the test helpers as follows:
305
+
306
+ ```ruby
307
+ # we must explicitly require the test helpers
308
+ require 'aws/lex/conversation/spec'
309
+
310
+ # optional: include the custom matchers if you're using RSpec
311
+ RSpec.configure do |config|
312
+ config.include(Aws::Lex::Conversation::Spec)
313
+ end
314
+
315
+ # we can now simulate state in a test somewhere
316
+ it 'simulates a conversation' do
317
+ conversation # given we have an instance of Aws::Lex::Conversation
318
+ .simulate! # simulation modifies the underlying instance
319
+ .transcript('My age is 21') # optionally set an input transcript
320
+ .intent(name: 'MyCoolIntent') # route to the intent named "MyCoolIntent"
321
+ .slot(name: 'Age', value: '21') # add a slot named "Age" with a corresponding value
322
+
323
+ expect(conversation).to have_transcript('My age is 21')
324
+ expect(conversation).to route_to_intent('MyCoolIntent')
325
+ expect(conversation).to have_slot(name: 'Age', value: '21')
326
+ end
327
+
328
+ # if you'd rather create your own event from scratch
329
+ it 'creates an event' do
330
+ simulator = Aws::Lex::Conversation::Simulator.new
331
+ simulator
332
+ .transcript('I am 21 years old.')
333
+ .input_mode('Speech')
334
+ .context(name: 'WelcomeGreetingCompleted')
335
+ .invocation_source('FulfillmentCodeHook')
336
+ .session(username: 'jane.doe')
337
+ .intent(
338
+ name: 'GuessZodiacSign',
339
+ state: 'ReadyForFulfillment',
340
+ slots: {
341
+ age: {
342
+ value: '21'
343
+ }
344
+ }
345
+ )
346
+ event = simulator.event
347
+
348
+ expect(event).to have_transcript('I am 21 years old.')
349
+ expect(event).to have_input_mode('Speech')
350
+ expect(event).to have_active_context(name: 'WelcomeGreetingCompleted')
351
+ expect(event).to have_invocation_source('FulfillmentCodeHook')
352
+ expect(event).to route_to_intent('GuessZodiacSign')
353
+ expect(event).to have_slot(name: 'age', value: '21')
354
+ expect(event).to include_session_values(username: 'jane.doe')
355
+ end
356
+ ```
357
+
194
358
  ## Development
195
359
 
196
360
  After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aws
4
+ module Lex
5
+ class Conversation
6
+ class Simulator
7
+ attr_accessor :lex
8
+
9
+ def initialize(opts = {})
10
+ self.lex = opts.fetch(:lex) do
11
+ Type::Event.shrink_wrap(
12
+ bot: default_bot,
13
+ inputMode: 'Text',
14
+ inputTranscript: '',
15
+ interpretations: [],
16
+ invocationSource: 'DialogCodeHook',
17
+ messageVersion: '1.0',
18
+ requestAttributes: {},
19
+ responseContentType: 'text/plain; charset=utf-8',
20
+ sessionId: '1234567890000',
21
+ sessionState: default_session_state
22
+ )
23
+ end
24
+ interpretation(name: 'SIMULATION')
25
+ end
26
+
27
+ def event
28
+ lex.to_lex
29
+ end
30
+
31
+ def bot(opts = {})
32
+ changes = {
33
+ aliasName: opts[:alias_name],
34
+ aliasId: opts[:alias_id],
35
+ name: opts[:name],
36
+ version: opts[:version],
37
+ localeId: opts[:locale_id],
38
+ id: opts[:id]
39
+ }.compact
40
+ lex.bot = Type::Bot.shrink_wrap(lex.bot.to_lex.merge(changes))
41
+ self
42
+ end
43
+
44
+ def transcript(message)
45
+ lex.input_transcript = message
46
+ self
47
+ end
48
+
49
+ def intent(opts = {})
50
+ data = default_intent(opts)
51
+ intent = Type::Intent.shrink_wrap(data)
52
+ lex.session_state.intent = intent
53
+ interpretation(data)
54
+ end
55
+
56
+ # rubocop:disable Metrics/AbcSize
57
+ def interpretation(opts = {})
58
+ name = opts.fetch(:name)
59
+ slots = opts.fetch(:slots) { {} }
60
+ sentiment_score = opts.dig(:sentiment_response, :sentiment_score)
61
+ sentiment = opts.dig(:sentiment_response, :sentiment)
62
+ sentiment_response = opts[:sentiment_response] && {
63
+ sentiment: sentiment,
64
+ sentimentScore: sentiment_score
65
+ }
66
+ data = {
67
+ intent: default_intent(opts),
68
+ sentimentResponse: sentiment_response,
69
+ nluConfidence: opts[:nlu_confidence]
70
+ }.compact
71
+ lex.interpretations.delete_if { |i| i.intent.name == name }
72
+ lex.interpretations << Type::Interpretation.shrink_wrap(data)
73
+ slots.each do |key, value|
74
+ slot_data = { name: key }.merge(value)
75
+ slot(slot_data)
76
+ end
77
+ reset_computed_properties!
78
+ self
79
+ end
80
+ # rubocop:enable Metrics/AbcSize
81
+
82
+ def context(opts = {})
83
+ data = {
84
+ name: opts.fetch(:name),
85
+ contextAttributes: opts.fetch(:context_attributes) { {} },
86
+ timeToLive: {
87
+ timeToLiveInSeconds: opts.fetch(:seconds) { 600 },
88
+ turnsToLive: opts.fetch(:turns) { 100 }
89
+ }
90
+ }
91
+ context = Type::Context.shrink_wrap(data)
92
+ lex.session_state.active_contexts.delete_if { |c| c.name == context.name }
93
+ lex.session_state.active_contexts << context
94
+ self
95
+ end
96
+
97
+ def slot(opts = {})
98
+ name = opts.fetch(:name).to_sym
99
+ raw_slots = {
100
+ shape: opts.fetch(:shape) { 'Scalar' },
101
+ value: {
102
+ originalValue: opts.fetch(:original_value) { opts.fetch(:value) },
103
+ resolvedValues: opts.fetch(:resolved_values) { [opts.fetch(:value)] },
104
+ interpretedValue: opts.fetch(:interpreted_value) { opts.fetch(:value) }
105
+ }
106
+ }
107
+ lex.session_state.intent.raw_slots[name] = raw_slots
108
+ current_interpretation.intent.raw_slots[name] = raw_slots
109
+ reset_computed_properties!
110
+ self
111
+ end
112
+
113
+ def invocation_source(source)
114
+ lex.invocation_source = Type::InvocationSource.new(source)
115
+ self
116
+ end
117
+
118
+ def input_mode(mode)
119
+ lex.input_mode = Type::InputMode.new(mode)
120
+ self
121
+ end
122
+
123
+ def session(data)
124
+ lex.session_state.session_attributes.merge!(Type::SessionAttributes[data])
125
+ self
126
+ end
127
+
128
+ private
129
+
130
+ def current_interpretation
131
+ lex.interpretations.find { |i| i.intent.name == lex.session_state.intent.name }
132
+ end
133
+
134
+ # computed properties are memoized using instance variables, so we must
135
+ # uncache the values when things change
136
+ def reset_computed_properties!
137
+ %w[
138
+ @alternate_intents
139
+ @current_intent
140
+ @intents
141
+ ].each do |variable|
142
+ lex.instance_variable_set(variable, nil)
143
+ end
144
+
145
+ lex.session_state.intent.instance_variable_set('@slots', nil)
146
+ current_interpretation.intent.instance_variable_set('@slots', nil)
147
+ end
148
+
149
+ def default_bot
150
+ {
151
+ aliasId: 'TSTALIASID',
152
+ aliasName: 'TestBotAlias',
153
+ id: 'BOT_ID',
154
+ localeId: 'en_US',
155
+ name: 'SIMULATOR',
156
+ version: 'DRAFT'
157
+ }
158
+ end
159
+
160
+ def default_session_state
161
+ {
162
+ activeContexts: [],
163
+ sessionAttributes: {},
164
+ intent: {
165
+ confirmationState: 'None',
166
+ name: 'SIMULATION',
167
+ slots: {},
168
+ state: 'InProgress',
169
+ originatingRequestId: SecureRandom.uuid
170
+ }
171
+ }
172
+ end
173
+
174
+ def default_intent(opts = {})
175
+ {
176
+ confirmationState: opts.fetch(:confirmation_state) { 'None' },
177
+ kendraResponse: opts[:kendra_response],
178
+ name: opts.fetch(:name),
179
+ nluConfidence: opts[:nlu_confidence],
180
+ originatingRequestId: opts.fetch(:originating_request_id) { SecureRandom.uuid },
181
+ slots: opts.fetch(:slots) { {} },
182
+ state: opts.fetch(:state) { 'InProgress' }
183
+ }.compact
184
+ end
185
+ end
186
+ end
187
+ end
188
+ end
@@ -6,7 +6,7 @@ module Aws
6
6
  module Slot
7
7
  class Elicitation
8
8
  attr_accessor :name, :elicit, :messages, :follow_up_messages,
9
- :fallback, :maximum_elicitations, :conversation
9
+ :fallback, :max_retries, :conversation
10
10
 
11
11
  def initialize(opts = {})
12
12
  self.name = opts.fetch(:name)
@@ -14,7 +14,7 @@ module Aws
14
14
  self.messages = opts.fetch(:messages)
15
15
  self.follow_up_messages = opts.fetch(:follow_up_messages) { opts.fetch(:messages) }
16
16
  self.fallback = opts[:fallback]
17
- self.maximum_elicitations = opts.fetch(:maximum_elicitations) { 0 }
17
+ self.max_retries = opts[:max_retries] || opts[:maximum_elicitations] || 0
18
18
  end
19
19
 
20
20
  def elicit!
@@ -53,9 +53,7 @@ module Aws
53
53
  end
54
54
 
55
55
  def maximum_elicitations?
56
- return false if maximum_elicitations.zero?
57
-
58
- elicitation_attempts > maximum_elicitations
56
+ elicitation_attempts > max_retries
59
57
  end
60
58
 
61
59
  def first_elicitation?
@@ -0,0 +1,236 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Aws
4
+ module Lex
5
+ class Conversation
6
+ module Spec
7
+ module Matchers
8
+ extend RSpec::Matchers::DSL
9
+
10
+ # :nocov:
11
+ def build_event(input)
12
+ case input
13
+ when Aws::Lex::Conversation
14
+ input.lex.to_lex
15
+ when Hash
16
+ input
17
+ else
18
+ raise ArgumentError, \
19
+ 'expected instance of Aws::Lex::Conversation ' \
20
+ "or Hash, got: #{input.inspect}"
21
+ end
22
+ end
23
+ # :nocov:
24
+
25
+ matcher(:have_transcript) do |expected|
26
+ match do |actual|
27
+ build_event(actual).fetch(:inputTranscript) == expected
28
+ end
29
+
30
+ # :nocov:
31
+ failure_message do |actual|
32
+ response = build_event(actual)
33
+ "expected a transcript of '#{expected}', got '#{response.fetch(:inputTranscript)}'"
34
+ end
35
+
36
+ failure_message_when_negated do |actual|
37
+ response = build_event(actual)
38
+ "expected transcript to not equal '#{expected}', got '#{response.fetch(:inputTranscript)}'"
39
+ end
40
+ # :nocov:
41
+ end
42
+
43
+ matcher(:route_to_intent) do |expected|
44
+ match do |actual|
45
+ build_event(actual).dig(:sessionState, :intent, :name) == expected
46
+ end
47
+
48
+ # :nocov:
49
+ failure_message do |actual|
50
+ response = build_event(actual)
51
+ "expected intent to be '#{expected}', got '#{response.dig(:sessionState, :intent, :name)}'"
52
+ end
53
+
54
+ failure_message_when_negated do |actual|
55
+ response = build_event(actual)
56
+ "expected intent to not be '#{expected}', got '#{response.dig(:sessionState, :intent, :name)}'"
57
+ end
58
+ # :nocov:
59
+ end
60
+
61
+ matcher(:have_active_context) do |expected|
62
+ match do |actual|
63
+ build_event(actual).dig(:sessionState, :activeContexts).any? { |c| c[:name] == expected }
64
+ end
65
+
66
+ # :nocov:
67
+ failure_message do |actual|
68
+ response = build_event(actual)
69
+ names = response.dig(:sessionState, :activeContexts).map { |c| c[:name] }.inspect
70
+ "expected active context of `#{expected}` in #{names}"
71
+ end
72
+
73
+ failure_message_when_negated do |actual|
74
+ response = build_event(actual)
75
+ names = response.dig(:sessionState, :activeContexts).map { |c| c[:name] }.inspect
76
+ "expected active contexts to not include `#{expected}`, got: #{names}"
77
+ end
78
+ # :nocov:
79
+ end
80
+
81
+ matcher(:have_invocation_source) do |expected|
82
+ match do |actual|
83
+ build_event(actual).fetch(:invocationSource) == expected
84
+ end
85
+
86
+ # :nocov:
87
+ failure_message do |actual|
88
+ response = build_event(actual)
89
+ "expected invocationSource of `#{expected}`, got: #{response[:invocationSource]}"
90
+ end
91
+
92
+ failure_message_when_negated do |actual|
93
+ response = build_event(actual)
94
+ "expected invocationSource to not be `#{expected}`, got: #{response[:invocationSource]}"
95
+ end
96
+ # :nocov:
97
+ end
98
+
99
+ matcher(:have_input_mode) do |expected|
100
+ match do |actual|
101
+ build_event(actual).fetch(:inputMode) == expected
102
+ end
103
+
104
+ # :nocov:
105
+ failure_message do |actual|
106
+ response = build_event(actual)
107
+ "expected inputMode of `#{expected}`, got: #{response[:inputMode]}"
108
+ end
109
+
110
+ failure_message_when_negated do |actual|
111
+ response = build_event(actual)
112
+ "expected inputMode to not be `#{expected}`, got: #{response[:inputMode]}"
113
+ end
114
+ # :nocov:
115
+ end
116
+
117
+ matcher(:have_interpretation) do |expected|
118
+ match do |actual|
119
+ build_event(actual).fetch(:interpretations).any? { |i| i.dig(:intent, :name) == expected }
120
+ end
121
+
122
+ # :nocov:
123
+ failure_message do |actual|
124
+ response = build_event(actual)
125
+ names = response[:interpretations].map { |i| i.dig(:intent, :name) }.inspect
126
+ "expected interpretation of `#{expected}` in #{names}"
127
+ end
128
+
129
+ failure_message_when_negated do |actual|
130
+ response = build_event(actual)
131
+ names = response[:interpretations].map { |_i| c.dig(:intent, :name) }.inspect
132
+ "expected interpretations to not include `#{expected}`, got: #{names}"
133
+ end
134
+ # :nocov:
135
+ end
136
+
137
+ matcher(:have_slot) do |opts|
138
+ name = opts.fetch(:name)
139
+ value = opts[:value]
140
+ values = value && [value]
141
+ expected_slot = {
142
+ shape: opts.fetch(:shape) { 'Scalar' },
143
+ value: {
144
+ originalValue: opts.fetch(:original_value, value),
145
+ interpretedValue: opts.fetch(:interpreted_value, value),
146
+ resolvedValues: opts.fetch(:resolved_values, values)
147
+ }.compact
148
+ }
149
+
150
+ match do |actual|
151
+ slot = build_event(actual).dig(:sessionState, :intent, :slots, name.to_sym)
152
+ slot[:shape] == expected_slot[:shape] &&
153
+ slot[:value].slice(*expected_slot[:value].keys) == expected_slot[:value]
154
+ end
155
+
156
+ # :nocov:
157
+ failure_message do |actual|
158
+ slot = build_event(actual).dig(:sessionState, :intent, :slots, name.to_sym)
159
+ "expected #{expected_slot.inspect} to equal #{slot.inspect}"
160
+ end
161
+
162
+ failure_message_when_negated do |actual|
163
+ slot = build_event(actual).dig(:sessionState, :intent, :slots, name.to_sym)
164
+ "expected #{expected_slot.inspect} to not equal #{slot.inspect}"
165
+ end
166
+ # :nocov:
167
+ end
168
+
169
+ matcher(:have_action) do |expected|
170
+ match do |actual|
171
+ build_event(actual).dig(:sessionState, :dialogAction, :type) == expected
172
+ end
173
+
174
+ # :nocov:
175
+ failure_message do |actual|
176
+ "expected #{build_event(actual).dig(:sessionState, :dialogAction, :type)} to equal #{expected}"
177
+ end
178
+
179
+ failure_message_when_negated do |actual|
180
+ "expected #{build_event(actual).dig(:sessionState, :dialogAction, :type)} to not equal #{expected}"
181
+ end
182
+ # :nocov:
183
+ end
184
+
185
+ matcher(:have_intent_state) do |expected|
186
+ match do |actual|
187
+ build_event(actual).dig(:sessionState, :intent, :state) == expected
188
+ end
189
+
190
+ # :nocov:
191
+ failure_message do |actual|
192
+ "expected #{build_event(actual).dig(:sessionState, :intent, :state)} to equal #{expected}"
193
+ end
194
+
195
+ failure_message_when_negated do |actual|
196
+ "expected #{build_event(actual).dig(:sessionState, :intent, :state)} to not equal #{expected}"
197
+ end
198
+ # :nocov:
199
+ end
200
+
201
+ matcher(:elicit_slot) do |expected|
202
+ match do |actual|
203
+ response = build_event(actual)
204
+
205
+ response.dig(:sessionState, :dialogAction, :type) == 'ElicitSlot' &&
206
+ response.dig(:sessionState, :dialogAction, :slotToElicit) == expected
207
+ end
208
+ end
209
+
210
+ matcher(:include_session_values) do |expected|
211
+ match do |actual|
212
+ response = build_event(actual)
213
+ response.dig(:sessionState, :sessionAttributes).slice(*expected.keys) == expected
214
+ end
215
+ end
216
+
217
+ matcher(:have_message) do |expected|
218
+ match do |actual|
219
+ build_event(actual)[:messages].any? { |m| m.slice(*expected.keys) == expected }
220
+ end
221
+
222
+ # :nocov:
223
+ failure_message do |actual|
224
+ "expected matching message of #{expected.inspect} in #{build_event(actual)[:messages].inspect}"
225
+ end
226
+
227
+ failure_message_when_negated do |actual|
228
+ "found matching message of #{expected.inspect} in #{build_event(actual)[:messages].inspect}"
229
+ end
230
+ # :nocov:
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end
236
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rspec/expectations'
4
+ require_relative 'simulator'
5
+ require_relative 'spec/matchers'
6
+
7
+ module Aws
8
+ module Lex
9
+ class Conversation
10
+ module Spec
11
+ def self.included(base)
12
+ base.include(Matchers)
13
+ end
14
+ end
15
+
16
+ def simulate!
17
+ @simulate ||= Simulator.new(lex: lex)
18
+ end
19
+ end
20
+ end
21
+ end
@@ -71,14 +71,19 @@ module Aws
71
71
  ->(v) { v.transform_keys(&:to_sym) }
72
72
  end
73
73
 
74
- def computed_property(attribute, callable)
75
- mapping[attribute] = attribute
74
+ def computed_property(attribute, opts = {}, &block)
76
75
  attr_writer(attribute)
77
76
 
77
+ if opts.fetch(:virtual) { false }
78
+ virtual_attributes << attribute
79
+ else
80
+ mapping[attribute] = attribute
81
+ end
82
+
78
83
  # dynamically memoize the result
79
84
  define_method(attribute) do
80
85
  instance_variable_get("@#{attribute}") ||
81
- instance_variable_set("@#{attribute}", callable.call(self))
86
+ instance_variable_set("@#{attribute}", block.call(self))
82
87
  end
83
88
  end
84
89
 
@@ -8,6 +8,7 @@ module Aws
8
8
  include Base
9
9
 
10
10
  required :alias_id
11
+ required :alias_name
11
12
  required :id
12
13
  required :locale_id
13
14
  required :name
@@ -20,6 +20,34 @@ module Aws
20
20
  fulfillment_state: FulfillmentState
21
21
  )
22
22
 
23
+ class << self
24
+ def build(opts = {})
25
+ new(normalize_parameters(opts))
26
+ end
27
+
28
+ private
29
+
30
+ def normalize_parameters(opts)
31
+ params = opts.dup # we don't want to mutate our arguments
32
+
33
+ if params[:dialog_action_type].is_a?(String)
34
+ params[:dialog_action_type] = DialogActionType.new(params[:dialog_action_type])
35
+ end
36
+
37
+ if params[:fulfillment_state].is_a?(String)
38
+ params[:fulfillment_state] = FulfillmentState.new(params[:fulfillment_state])
39
+ end
40
+
41
+ params
42
+ end
43
+ end
44
+
45
+ # restore the checkpoint AND remove it from session
46
+ def restore!(conversation, opts = {})
47
+ conversation.checkpoints.delete_if { |c| c.label == label }
48
+ restore(conversation, opts)
49
+ end
50
+
23
51
  # rubocop:disable Metrics/MethodLength, Metrics/AbcSize
24
52
  def restore(conversation, opts = {})
25
53
  case dialog_action_type.raw
@@ -7,9 +7,9 @@ module Aws
7
7
  class Context
8
8
  include Base
9
9
 
10
- required :context_attributes
11
10
  required :name
12
11
  required :time_to_live
12
+ required :context_attributes, default: -> { {} }
13
13
 
14
14
  coerce(
15
15
  context_attributes: symbolize_hash!,
@@ -18,21 +18,21 @@ module Aws
18
18
  required :session_id
19
19
  required :session_state
20
20
 
21
- computed_property :current_intent, ->(instance) do
21
+ computed_property(:current_intent, virtual: true) do |instance|
22
22
  instance.session_state.intent.tap do |intent|
23
- intent.nlu_confidence = instance.interpretations.find { |i| i.intent.name == intent.name }.nlu_confidence
23
+ intent.nlu_confidence = instance.interpretations.find { |i| i.intent.name == intent.name }&.nlu_confidence
24
24
  end
25
25
  end
26
26
 
27
- computed_property :intents, ->(instance) do
27
+ computed_property(:intents, virutal: true) do |instance|
28
28
  instance.interpretations.map(&:intent).tap do |intents|
29
29
  intents.map do |intent|
30
- intent.nlu_confidence = instance.interpretations.find { |i| i.intent.name == intent.name }.nlu_confidence
30
+ intent.nlu_confidence = instance.interpretations.find { |i| i.intent.name == intent.name }&.nlu_confidence
31
31
  end
32
32
  end
33
33
  end
34
34
 
35
- computed_property :alternate_intents, ->(instance) do
35
+ computed_property(:alternate_intents, virtual: true) do |instance|
36
36
  instance.intents.reject { |intent| intent.name == instance.current_intent.name }
37
37
  end
38
38
 
@@ -15,7 +15,7 @@ module Aws
15
15
  optional :originating_request_id
16
16
  optional :nlu_confidence
17
17
 
18
- computed_property :slots, ->(instance) do
18
+ computed_property(:slots) do |instance|
19
19
  # any keys indexed without a value will return an empty Slot instance
20
20
  default_hash = Hash.new do |_hash, key|
21
21
  Slot.shrink_wrap(active: false, name: key.to_sym, value: nil, shape: 'Scalar')
@@ -20,10 +20,7 @@ module Aws
20
20
  )
21
21
 
22
22
  def to_lex
23
- super.merge(
24
- value: transform_to_lex(lex_value),
25
- values: transform_to_lex(lex_values)
26
- )
23
+ super.merge(extra_response_attributes)
27
24
  end
28
25
 
29
26
  def value=(val)
@@ -35,7 +32,7 @@ module Aws
35
32
  def value
36
33
  raise TypeError, 'use values for List-type slots' if shape.list?
37
34
 
38
- lex_value.interpreted_value
35
+ lex_value&.interpreted_value
39
36
  end
40
37
 
41
38
  # takes an array of slot values
@@ -87,6 +84,20 @@ module Aws
87
84
  def requestable?
88
85
  active? && blank?
89
86
  end
87
+
88
+ private
89
+
90
+ def extra_response_attributes
91
+ if shape.list?
92
+ {
93
+ values: transform_to_lex(lex_values)
94
+ }
95
+ else
96
+ {
97
+ value: transform_to_lex(lex_value)
98
+ }
99
+ end
100
+ end
90
101
  end
91
102
  end
92
103
  end
@@ -3,7 +3,7 @@
3
3
  module Aws
4
4
  module Lex
5
5
  class Conversation
6
- VERSION = '5.1.1'
6
+ VERSION = '6.2.0'
7
7
  end
8
8
  end
9
9
  end
@@ -62,9 +62,9 @@ module Aws
62
62
  label = opts.fetch(:label)
63
63
  params = {
64
64
  label: label,
65
- dialog_action_type: opts.fetch(:dialog_action_type),
65
+ dialog_action_type: opts.fetch(:dialog_action_type) { 'Delegate' },
66
66
  fulfillment_state: opts[:fulfillment_state],
67
- intent: lex.current_intent,
67
+ intent: opts.fetch(:intent) { lex.current_intent },
68
68
  slot_to_elicit: opts[:slot_to_elicit]
69
69
  }.compact
70
70
 
@@ -72,9 +72,8 @@ module Aws
72
72
  # update the existing checkpoint
73
73
  checkpoint(label: label).assign_attributes!(params)
74
74
  else
75
- # push a new checkpoint to the recent_intent_summary_view
76
75
  checkpoints.unshift(
77
- Type::Checkpoint.new(params)
76
+ Type::Checkpoint.build(params)
78
77
  )
79
78
  end
80
79
  end
@@ -91,6 +90,58 @@ module Aws
91
90
  lex.session_state.session_attributes.checkpoints
92
91
  end
93
92
 
93
+ def restore_from!(checkpoint)
94
+ # we're done with the stored checkpoint once it's been restored
95
+ checkpoints.delete_if { |c| c.label == checkpoint.label }
96
+ # remove any memoized intent data
97
+ lex.current_intent = nil
98
+ # replace the intent with data from the checkpoint
99
+ lex.session_state.intent = checkpoint.intent
100
+ dialog_action = Type::DialogAction.new(
101
+ type: checkpoint.dialog_action_type,
102
+ slot_to_elicit: checkpoint.slot_to_elicit
103
+ )
104
+ lex.session_state.dialog_action = dialog_action
105
+ self
106
+ end
107
+
108
+ def active_context?(name:)
109
+ !active_context(name: name).nil?
110
+ end
111
+
112
+ def active_context(name:)
113
+ lex.session_state.active_contexts.find { |c| c.name == name }
114
+ end
115
+
116
+ def active_context!(name:, turns: 10, seconds: 300, attributes: {})
117
+ # look for an existing active context if present
118
+ instance = active_context(name: name)
119
+
120
+ if instance
121
+ clear_context!(name: name)
122
+ else
123
+ instance = Type::Context.new
124
+ end
125
+
126
+ # update attributes as requested
127
+ instance.name = name
128
+ instance.context_attributes = attributes
129
+ instance.time_to_live = Type::TimeToLive.new(
130
+ turns_to_live: turns,
131
+ time_to_live_in_seconds: seconds
132
+ )
133
+ lex.session_state.active_contexts << instance
134
+ instance
135
+ end
136
+
137
+ def clear_context!(name:)
138
+ lex.session_state.active_contexts.delete_if { |c| c.name == name }
139
+ end
140
+
141
+ def clear_all_contexts!
142
+ lex.session_state.active_contexts = []
143
+ end
144
+
94
145
  def stash
95
146
  @stash ||= {}
96
147
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: aws-lex-conversation
3
3
  version: !ruby/object:Gem::Version
4
- version: 5.1.1
4
+ version: 6.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jesse Doyle
@@ -12,7 +12,7 @@ authors:
12
12
  autorequire:
13
13
  bindir: exe
14
14
  cert_chain: []
15
- date: 2021-09-03 00:00:00.000000000 Z
15
+ date: 2021-09-28 00:00:00.000000000 Z
16
16
  dependencies:
17
17
  - !ruby/object:Gem::Dependency
18
18
  name: shrink_wrap
@@ -67,8 +67,11 @@ files:
67
67
  - lib/aws/lex/conversation/response/delegate.rb
68
68
  - lib/aws/lex/conversation/response/elicit_intent.rb
69
69
  - lib/aws/lex/conversation/response/elicit_slot.rb
70
+ - lib/aws/lex/conversation/simulator.rb
70
71
  - lib/aws/lex/conversation/slot/elicitation.rb
71
72
  - lib/aws/lex/conversation/slot/elicitor.rb
73
+ - lib/aws/lex/conversation/spec.rb
74
+ - lib/aws/lex/conversation/spec/matchers.rb
72
75
  - lib/aws/lex/conversation/support/inflector.rb
73
76
  - lib/aws/lex/conversation/support/mixins/responses.rb
74
77
  - lib/aws/lex/conversation/support/mixins/slot_elicitation.rb