james 0.5.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.
- data/aux/james/cli.rb +54 -0
- data/bin/james +26 -0
- data/lib/james.rb +50 -0
- data/lib/james/builtin/core_dialog.rb +52 -0
- data/lib/james/controller.rb +106 -0
- data/lib/james/conversation.rb +62 -0
- data/lib/james/dialog_api.rb +38 -0
- data/lib/james/dialog_internals.rb +105 -0
- data/lib/james/dialogs.rb +21 -0
- data/lib/james/framework.rb +1 -0
- data/lib/james/inputs/audio.rb +47 -0
- data/lib/james/inputs/base.rb +32 -0
- data/lib/james/inputs/terminal.rb +37 -0
- data/lib/james/markers/current.rb +34 -0
- data/lib/james/markers/marker.rb +94 -0
- data/lib/james/markers/memory.rb +37 -0
- data/lib/james/outputs/audio.rb +31 -0
- data/lib/james/outputs/terminal.rb +27 -0
- data/lib/james/state_api.rb +123 -0
- data/lib/james/state_internals.rb +59 -0
- metadata +85 -0
data/aux/james/cli.rb
ADDED
@@ -0,0 +1,54 @@
|
|
1
|
+
require File.expand_path '../../../lib/james', __FILE__
|
2
|
+
|
3
|
+
module James
|
4
|
+
|
5
|
+
class CLI
|
6
|
+
|
7
|
+
def execute *patterns
|
8
|
+
options = extract_options patterns
|
9
|
+
|
10
|
+
dialogs = find_dialogs_for patterns
|
11
|
+
|
12
|
+
puts "James: I haven't found anything to talk about (No files found). Exiting." or exit!(1) if dialogs.empty?
|
13
|
+
puts "James: Using dialogs in #{dialogs.join(', ')} for our conversation, Sir."
|
14
|
+
|
15
|
+
load_all dialogs
|
16
|
+
|
17
|
+
James.listen options
|
18
|
+
end
|
19
|
+
|
20
|
+
# Defines default options and extracts options from
|
21
|
+
# command line.
|
22
|
+
#
|
23
|
+
# Sadly needs to be run before processing the dialog file names.
|
24
|
+
#
|
25
|
+
def extract_options patterns
|
26
|
+
silent = patterns.delete '-s'
|
27
|
+
silent_input = patterns.delete '-si'
|
28
|
+
silent_output = patterns.delete '-so'
|
29
|
+
|
30
|
+
options = {}
|
31
|
+
options[:input] = Inputs::Terminal if silent || silent_input
|
32
|
+
options[:output] = Outputs::Terminal if silent || silent_output
|
33
|
+
|
34
|
+
options
|
35
|
+
end
|
36
|
+
|
37
|
+
#
|
38
|
+
#
|
39
|
+
def find_dialogs_for patterns
|
40
|
+
patterns = ["**/*_dialog{,ue}.rb"] if patterns.empty?
|
41
|
+
Dir[*patterns]
|
42
|
+
end
|
43
|
+
|
44
|
+
#
|
45
|
+
#
|
46
|
+
def load_all dialogs
|
47
|
+
dialogs.each do |dialog|
|
48
|
+
load File.expand_path dialog, Dir.pwd
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
53
|
+
|
54
|
+
end
|
data/bin/james
ADDED
@@ -0,0 +1,26 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
#
|
3
|
+
|
4
|
+
# james joke_dialog.rb phonebook_dialog.rb quote_dialog.rb # Uses just the described ones.
|
5
|
+
# OR
|
6
|
+
# james j*.rb # Uses all the dialogs matching the pattern.
|
7
|
+
# OR
|
8
|
+
# james # Uses all dialogs it can find in this dir and subdirs.
|
9
|
+
#
|
10
|
+
# Options:
|
11
|
+
# * -s # Silent input and output (same as "-si -so").
|
12
|
+
# * -si # Silent input.
|
13
|
+
# * -so # Silent output.
|
14
|
+
#
|
15
|
+
|
16
|
+
begin
|
17
|
+
require 'james/cli'
|
18
|
+
rescue LoadError => e
|
19
|
+
require 'rubygems'
|
20
|
+
james_path = File.expand_path '../../aux', __FILE__
|
21
|
+
$:.unshift(james_path) if File.directory?(james_path) && !$:.include?(james_path)
|
22
|
+
require 'james/cli'
|
23
|
+
end
|
24
|
+
|
25
|
+
cli = James::CLI.new
|
26
|
+
cli.execute *ARGV
|
data/lib/james.rb
ADDED
@@ -0,0 +1,50 @@
|
|
1
|
+
module James; end
|
2
|
+
|
3
|
+
require File.expand_path '../james/state_api', __FILE__
|
4
|
+
require File.expand_path '../james/state_internals', __FILE__
|
5
|
+
|
6
|
+
require File.expand_path '../james/markers/marker', __FILE__
|
7
|
+
require File.expand_path '../james/markers/current', __FILE__
|
8
|
+
require File.expand_path '../james/markers/memory', __FILE__
|
9
|
+
require File.expand_path '../james/conversation', __FILE__
|
10
|
+
|
11
|
+
require File.expand_path '../james/dialog_api', __FILE__
|
12
|
+
require File.expand_path '../james/dialog_internals', __FILE__
|
13
|
+
|
14
|
+
require File.expand_path '../james/dialogs', __FILE__
|
15
|
+
|
16
|
+
require File.expand_path '../james/inputs/base', __FILE__
|
17
|
+
require File.expand_path '../james/inputs/audio', __FILE__
|
18
|
+
require File.expand_path '../james/inputs/terminal', __FILE__
|
19
|
+
|
20
|
+
require File.expand_path '../james/outputs/audio', __FILE__
|
21
|
+
require File.expand_path '../james/outputs/terminal', __FILE__
|
22
|
+
|
23
|
+
require File.expand_path '../james/builtin/core_dialog', __FILE__
|
24
|
+
|
25
|
+
require File.expand_path '../james/framework', __FILE__
|
26
|
+
require File.expand_path '../james/controller', __FILE__
|
27
|
+
|
28
|
+
module James
|
29
|
+
|
30
|
+
# Use the given dialogs.
|
31
|
+
#
|
32
|
+
# If called twice or more, will just add more dialogs.
|
33
|
+
#
|
34
|
+
def self.use *dialogs
|
35
|
+
dialogs.each { |dialog| controller << dialog}
|
36
|
+
end
|
37
|
+
|
38
|
+
# Start listening.
|
39
|
+
#
|
40
|
+
def self.listen options
|
41
|
+
controller.listen options
|
42
|
+
end
|
43
|
+
|
44
|
+
# Controller instance.
|
45
|
+
#
|
46
|
+
def self.controller
|
47
|
+
Controller.instance
|
48
|
+
end
|
49
|
+
|
50
|
+
end
|
@@ -0,0 +1,52 @@
|
|
1
|
+
# This is the core dialog every dialog will be hooking into.
|
2
|
+
#
|
3
|
+
# Eventually, the design should be such that everyone can use
|
4
|
+
# the design and core dialog they like best.
|
5
|
+
#
|
6
|
+
# But to get going, this suffices for now.
|
7
|
+
#
|
8
|
+
class CoreDialog
|
9
|
+
|
10
|
+
include James::Dialog
|
11
|
+
|
12
|
+
# This core dialog starts at awake.
|
13
|
+
#
|
14
|
+
initially :awake
|
15
|
+
|
16
|
+
# The alert state.
|
17
|
+
# When James is in this state, he should be
|
18
|
+
# open for user dialogs.
|
19
|
+
#
|
20
|
+
state :awake do
|
21
|
+
# If James is awake, he offers more dialogs
|
22
|
+
# on this state, if there are any hooked into this state.
|
23
|
+
#
|
24
|
+
chainable
|
25
|
+
|
26
|
+
hear "Thank you, James." => :awake,
|
27
|
+
'I need some time alone, James.' => :away,
|
28
|
+
"Good night, James." => :exit
|
29
|
+
into { "Sir?" }
|
30
|
+
end
|
31
|
+
|
32
|
+
# The away state. James does not listen to any
|
33
|
+
# user dialog hooks, but only for his name
|
34
|
+
# or the good night, i.e. exit phrase.
|
35
|
+
#
|
36
|
+
state :away do
|
37
|
+
hear 'James?' => :awake,
|
38
|
+
"Good night, James." => :exit
|
39
|
+
into { "Of course, Sir!" }
|
40
|
+
end
|
41
|
+
|
42
|
+
# This is not a real state. It just exists to Kernel.exit
|
43
|
+
# James when he enters this state.
|
44
|
+
#
|
45
|
+
state :exit do
|
46
|
+
into do
|
47
|
+
puts "James: Exits through a side door."
|
48
|
+
Kernel.exit
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
end
|
@@ -0,0 +1,106 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
class Controller
|
4
|
+
|
5
|
+
attr_reader :conversation, :listening, :initial
|
6
|
+
|
7
|
+
# Singleton reader.
|
8
|
+
#
|
9
|
+
def self.instance
|
10
|
+
@controller ||= new
|
11
|
+
end
|
12
|
+
|
13
|
+
# This puts together the initial dialog and the user
|
14
|
+
# ones that are hooked into it.
|
15
|
+
#
|
16
|
+
# The initial dialog needs an state defined as initially.
|
17
|
+
# This is where it will start.
|
18
|
+
#
|
19
|
+
# Example:
|
20
|
+
# initially :awake
|
21
|
+
# state :awake do
|
22
|
+
# # ...
|
23
|
+
# end
|
24
|
+
#
|
25
|
+
# If you don't give it an initial dialog,
|
26
|
+
# James will simply use the built-in CoreDialog.
|
27
|
+
#
|
28
|
+
def initialize dialog = nil
|
29
|
+
@initial = dialog || CoreDialog.new
|
30
|
+
@conversation = Conversation.new @initial.current
|
31
|
+
end
|
32
|
+
|
33
|
+
# Convenience method to add a dialog to the current system.
|
34
|
+
#
|
35
|
+
# Will add the dialog to the initial dialog passed into the
|
36
|
+
# controller.
|
37
|
+
#
|
38
|
+
def << dialog
|
39
|
+
@initial << dialog
|
40
|
+
end
|
41
|
+
|
42
|
+
# Start listening using the provided options.
|
43
|
+
#
|
44
|
+
# Options:
|
45
|
+
# * input # Inputs::Terminal or Inputs::Audio (default).
|
46
|
+
# * output # Outputs::Terminal or Outputs::Audio (default).
|
47
|
+
#
|
48
|
+
def listen options = {}
|
49
|
+
return if listening
|
50
|
+
|
51
|
+
@listening = true
|
52
|
+
|
53
|
+
@input_class = options[:input] || Inputs::Audio
|
54
|
+
@output_class = options[:output] || Outputs::Audio
|
55
|
+
|
56
|
+
@output_options ||= {}
|
57
|
+
@output_options[:voice] = options[:voice] || 'com.apple.speech.synthesis.voice.Alex'
|
58
|
+
|
59
|
+
app = NSApplication.sharedApplication
|
60
|
+
app.delegate = self
|
61
|
+
|
62
|
+
app.run
|
63
|
+
end
|
64
|
+
|
65
|
+
# The naughty privates of this class.
|
66
|
+
#
|
67
|
+
|
68
|
+
# MacRuby callback functions.
|
69
|
+
#
|
70
|
+
def applicationDidFinishLaunching notification
|
71
|
+
start_output
|
72
|
+
start_input
|
73
|
+
end
|
74
|
+
def windowWillClose notification
|
75
|
+
exit
|
76
|
+
end
|
77
|
+
|
78
|
+
# Start recognizing words.
|
79
|
+
#
|
80
|
+
def start_input
|
81
|
+
@input = @input_class.new self
|
82
|
+
@input.listen
|
83
|
+
end
|
84
|
+
# Start speaking.
|
85
|
+
#
|
86
|
+
def start_output
|
87
|
+
@output = @output_class.new @output_options
|
88
|
+
end
|
89
|
+
|
90
|
+
# Callback method from dialog.
|
91
|
+
#
|
92
|
+
def say text
|
93
|
+
@output.say text
|
94
|
+
end
|
95
|
+
def hear text
|
96
|
+
conversation.hear text do |response|
|
97
|
+
say response
|
98
|
+
end
|
99
|
+
end
|
100
|
+
def expects
|
101
|
+
conversation.expects
|
102
|
+
end
|
103
|
+
|
104
|
+
end
|
105
|
+
|
106
|
+
end
|
@@ -0,0 +1,62 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
# A conversation has a number of markers (position in dialog),
|
4
|
+
# whose dialogs are visited in order of preference.
|
5
|
+
#
|
6
|
+
# Why?
|
7
|
+
# Conversations have multiple points where they can be.
|
8
|
+
# (Politics, then this joke, then back again, finally "Oh, bye I have to go!")
|
9
|
+
#
|
10
|
+
class Conversation
|
11
|
+
|
12
|
+
attr_accessor :markers
|
13
|
+
|
14
|
+
# A Conversation keeps a stack of markers with
|
15
|
+
# an initial one.
|
16
|
+
#
|
17
|
+
def initialize initial
|
18
|
+
@markers = [initial]
|
19
|
+
end
|
20
|
+
|
21
|
+
# Hear tries all visitors in order
|
22
|
+
# until one hears a phrase he knows.
|
23
|
+
#
|
24
|
+
# If a dialog boundary has been crossed:
|
25
|
+
# A new visitor is added with the target
|
26
|
+
# state of that heard phrase at the position.
|
27
|
+
#
|
28
|
+
# After that, all remaining visitors are
|
29
|
+
# removed from the current stack (since
|
30
|
+
# we are obviously not in one of the later
|
31
|
+
# dialogs anymore).
|
32
|
+
#
|
33
|
+
def hear phrase, &block
|
34
|
+
self.markers = markers.inject([]) do |remaining, marker|
|
35
|
+
markers = marker.hear phrase, &block
|
36
|
+
remaining = remaining + markers
|
37
|
+
break remaining if remaining.last && remaining.last.current?
|
38
|
+
remaining
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
# Enter enters the first visitor.
|
43
|
+
#
|
44
|
+
def enter
|
45
|
+
markers.first.enter
|
46
|
+
end
|
47
|
+
|
48
|
+
# Simply returns the sum of what phrases all dialogs do expect, front-to-back.
|
49
|
+
#
|
50
|
+
# Stops as soon as a marker is not on a chainable state anymore.
|
51
|
+
#
|
52
|
+
def expects
|
53
|
+
markers.inject([]) do |expects, marker|
|
54
|
+
total = marker.expects + expects
|
55
|
+
break total unless marker.chainable?
|
56
|
+
total
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
end
|
61
|
+
|
62
|
+
end
|
@@ -0,0 +1,38 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
# A dialog can be instantiated in two ways:
|
4
|
+
#
|
5
|
+
# The simple way will directly add itself to James.
|
6
|
+
#
|
7
|
+
# James.dialog(optional_args_for_initialize) do
|
8
|
+
# # Your dialog.
|
9
|
+
# #
|
10
|
+
# end
|
11
|
+
#
|
12
|
+
# class MyDialog
|
13
|
+
#
|
14
|
+
# include James::Dialog
|
15
|
+
#
|
16
|
+
# # Your dialog definition.
|
17
|
+
# #
|
18
|
+
#
|
19
|
+
# end
|
20
|
+
#
|
21
|
+
# # Tell James to use the dialog.
|
22
|
+
# #
|
23
|
+
# James.use MyDialog.new
|
24
|
+
#
|
25
|
+
module Dialog; end
|
26
|
+
|
27
|
+
class << self
|
28
|
+
|
29
|
+
def dialog *args, &block
|
30
|
+
dialog = Class.new { include James::Dialog }
|
31
|
+
dialog.class_eval &block
|
32
|
+
use dialog.new(*args)
|
33
|
+
dialog
|
34
|
+
end
|
35
|
+
|
36
|
+
end
|
37
|
+
|
38
|
+
end
|
@@ -0,0 +1,105 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
# A dialog is just a container object
|
4
|
+
# for defining states and executing methods.
|
5
|
+
#
|
6
|
+
module Dialog
|
7
|
+
|
8
|
+
def self.included into
|
9
|
+
into.extend ClassMethods
|
10
|
+
end
|
11
|
+
|
12
|
+
# Returns a state instance for the given state / or state name.
|
13
|
+
#
|
14
|
+
# Note: Lazily creates the state instances.
|
15
|
+
#
|
16
|
+
def state_for possible_state
|
17
|
+
return possible_state if possible_state.respond_to?(:expects)
|
18
|
+
self.class.state_for possible_state, self
|
19
|
+
end
|
20
|
+
|
21
|
+
# Chain (the states of) this dialog to the given state.
|
22
|
+
#
|
23
|
+
# Creates state instances if it is given names.
|
24
|
+
#
|
25
|
+
# Note: Be careful not to create circular
|
26
|
+
# state chaining. Except if you really
|
27
|
+
# want that.
|
28
|
+
#
|
29
|
+
def chain_to state
|
30
|
+
warn "Define a hear => :some_state_name in a dialog to have it be able to chain to another." && return unless respond_to?(:entry_phrases)
|
31
|
+
entry_phrases.each do |(phrases, entry_state)|
|
32
|
+
state.hear phrases => state_for(entry_state)
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
# Chain the given Dialog(s) to all chainable
|
37
|
+
# states in this Dialog.
|
38
|
+
#
|
39
|
+
# Note: If you only want one state to chain,
|
40
|
+
# then get it from the otiginating dialog
|
41
|
+
# using dialog.state_for(:name) and
|
42
|
+
# append the dialog there:
|
43
|
+
# dialog.follows preceding_dialog.state_for(:name)
|
44
|
+
#
|
45
|
+
def << dialog_s
|
46
|
+
self.class.states.each do |(name, definition)|
|
47
|
+
state = state_for name # TODO Do not call this everywhere.
|
48
|
+
dialog_s.chain_to(state) if state.chainable?
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
module ClassMethods
|
53
|
+
|
54
|
+
def initially state_name
|
55
|
+
define_method :current do
|
56
|
+
Markers::Current.new state_for(state_name)
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
# Defines the entry phrases into this dialog.
|
61
|
+
#
|
62
|
+
# Example:
|
63
|
+
# hear 'Hello, James!' => :start
|
64
|
+
#
|
65
|
+
def hear definition
|
66
|
+
define_method :entry_phrases do
|
67
|
+
definition
|
68
|
+
end
|
69
|
+
end
|
70
|
+
|
71
|
+
# Defines a state with transitions.
|
72
|
+
#
|
73
|
+
# Example:
|
74
|
+
# state :name do
|
75
|
+
# # state properties (hear, into, exit) go here.
|
76
|
+
# end
|
77
|
+
#
|
78
|
+
def state name, &block
|
79
|
+
@states ||= {}
|
80
|
+
@states[name] ||= block if block_given?
|
81
|
+
define_method name do
|
82
|
+
state_for name
|
83
|
+
end unless instance_methods.include? name
|
84
|
+
end
|
85
|
+
|
86
|
+
#
|
87
|
+
#
|
88
|
+
attr_reader :states
|
89
|
+
|
90
|
+
# Return a state for this name (and dialog instance).
|
91
|
+
#
|
92
|
+
def state_for name, instance
|
93
|
+
# Lazily wrap in State instance.
|
94
|
+
#
|
95
|
+
if states[name].respond_to?(:call)
|
96
|
+
states[name] = State.new(name, instance, &states[name])
|
97
|
+
end
|
98
|
+
states[name]
|
99
|
+
end
|
100
|
+
|
101
|
+
end
|
102
|
+
|
103
|
+
end
|
104
|
+
|
105
|
+
end
|
@@ -0,0 +1,21 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
# Bundles a bunch of dialogs.
|
4
|
+
#
|
5
|
+
class Dialogs
|
6
|
+
|
7
|
+
attr_reader :dialogs
|
8
|
+
|
9
|
+
def initialize *dialogs
|
10
|
+
@dialogs = dialogs
|
11
|
+
end
|
12
|
+
|
13
|
+
#
|
14
|
+
#
|
15
|
+
def chain_to incoming_dialog
|
16
|
+
dialogs.each { |dialog| incoming_dialog << dialog }
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
20
|
+
|
21
|
+
end
|
@@ -0,0 +1 @@
|
|
1
|
+
framework 'AppKit'
|
@@ -0,0 +1,47 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Inputs
|
4
|
+
|
5
|
+
class Audio < Base
|
6
|
+
|
7
|
+
def initialize controller
|
8
|
+
super controller
|
9
|
+
@recognizer = NSSpeechRecognizer.alloc.init
|
10
|
+
@recognizer.setBlocksOtherRecognizers true
|
11
|
+
@recognizer.setListensInForegroundOnly false
|
12
|
+
@recognizer.setDelegate self
|
13
|
+
end
|
14
|
+
|
15
|
+
def listen
|
16
|
+
@recognizer.startListening
|
17
|
+
recognize_new_commands
|
18
|
+
end
|
19
|
+
def heard command
|
20
|
+
super
|
21
|
+
|
22
|
+
# Set recognizable commands.
|
23
|
+
#
|
24
|
+
recognize_new_commands
|
25
|
+
end
|
26
|
+
|
27
|
+
# Callback method from the speech interface.
|
28
|
+
#
|
29
|
+
# Note: Uses a MacRuby only form.
|
30
|
+
#
|
31
|
+
def speechRecognizer sender, didRecognizeCommand: command
|
32
|
+
heard command
|
33
|
+
end
|
34
|
+
def recognize_new_commands
|
35
|
+
possibilities = controller.expects
|
36
|
+
puts "Possibilities:\n"
|
37
|
+
possibilities.each_with_index do |possibility, index|
|
38
|
+
puts "#{index + 1}) #{possibility}"
|
39
|
+
end
|
40
|
+
@recognizer.setCommands possibilities
|
41
|
+
end
|
42
|
+
|
43
|
+
end
|
44
|
+
|
45
|
+
end
|
46
|
+
|
47
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Inputs
|
4
|
+
|
5
|
+
class Base
|
6
|
+
|
7
|
+
attr_reader :controller
|
8
|
+
|
9
|
+
def initialize controller
|
10
|
+
@controller = controller
|
11
|
+
end
|
12
|
+
|
13
|
+
# Call this method if you heard something in the subclass.
|
14
|
+
#
|
15
|
+
def heard command
|
16
|
+
controller.hear command
|
17
|
+
end
|
18
|
+
|
19
|
+
# Shows possible commands in the terminal.
|
20
|
+
#
|
21
|
+
def show_possibilities possibilities
|
22
|
+
puts "Possibilities:\n"
|
23
|
+
possibilities.each_with_index do |possibility, index|
|
24
|
+
puts "#{index + 1}) #{possibility}"
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Inputs
|
4
|
+
|
5
|
+
# Terminal input for silent purposes.
|
6
|
+
#
|
7
|
+
class Terminal < Base
|
8
|
+
|
9
|
+
# Start listening to commands by the user.
|
10
|
+
#
|
11
|
+
def listen
|
12
|
+
sleep 2
|
13
|
+
loop do
|
14
|
+
possibilities = controller.expects
|
15
|
+
show_possibilities possibilities
|
16
|
+
command = get_command
|
17
|
+
puts "I heard '#{command}'."
|
18
|
+
command = possibilities[command.to_i-1] unless command.to_i.zero?
|
19
|
+
heard command if possibilities.include? command
|
20
|
+
end
|
21
|
+
end
|
22
|
+
|
23
|
+
# Get the next command by the user.
|
24
|
+
#
|
25
|
+
def get_command
|
26
|
+
STDIN.gets.chop
|
27
|
+
rescue IOError
|
28
|
+
puts "Wait a second, please, Sir, I am busy."
|
29
|
+
sleep 1
|
30
|
+
retry
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Markers
|
4
|
+
|
5
|
+
# The visitor knows where in the conversation we are.
|
6
|
+
#
|
7
|
+
class Current < Marker
|
8
|
+
|
9
|
+
# Hear a phrase.
|
10
|
+
#
|
11
|
+
# Returns a new marker and self if it crossed a boundary.
|
12
|
+
# Returns itself if not.
|
13
|
+
#
|
14
|
+
def hear phrase, &block
|
15
|
+
return [self] unless hears? phrase
|
16
|
+
last = current
|
17
|
+
process(phrase, &block) ? [Memory.new(last), self] : [self]
|
18
|
+
end
|
19
|
+
|
20
|
+
# Expects all phrases, not just internal.
|
21
|
+
#
|
22
|
+
def expects
|
23
|
+
current.expects
|
24
|
+
end
|
25
|
+
|
26
|
+
def current?
|
27
|
+
true
|
28
|
+
end
|
29
|
+
|
30
|
+
end
|
31
|
+
|
32
|
+
end
|
33
|
+
|
34
|
+
end
|
@@ -0,0 +1,94 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Markers
|
4
|
+
|
5
|
+
# A marker is a point in conversation
|
6
|
+
# where we once were and might go back.
|
7
|
+
#
|
8
|
+
# TODO: Rename to ?.
|
9
|
+
#
|
10
|
+
class Marker
|
11
|
+
|
12
|
+
attr_accessor :current
|
13
|
+
|
14
|
+
# Pass in an current state.
|
15
|
+
#
|
16
|
+
def initialize current
|
17
|
+
@current = current
|
18
|
+
end
|
19
|
+
|
20
|
+
# Resets the current state back to the initial.
|
21
|
+
#
|
22
|
+
def reset
|
23
|
+
# Never moves, thus never reset.
|
24
|
+
end
|
25
|
+
|
26
|
+
# We hear a phrase.
|
27
|
+
#
|
28
|
+
# Also used to start the whole process.
|
29
|
+
#
|
30
|
+
def enter
|
31
|
+
result = current.__into__
|
32
|
+
yield result if result && block_given?
|
33
|
+
result
|
34
|
+
end
|
35
|
+
|
36
|
+
#
|
37
|
+
#
|
38
|
+
def exit
|
39
|
+
result = current.__exit__
|
40
|
+
yield result if result && block_given?
|
41
|
+
result
|
42
|
+
end
|
43
|
+
|
44
|
+
#
|
45
|
+
#
|
46
|
+
def transition phrase
|
47
|
+
state_or_lambda = current.next_for phrase
|
48
|
+
if state_or_lambda.respond_to?(:call)
|
49
|
+
result = current.__transition__ &state_or_lambda # Don't transition.
|
50
|
+
yield result if result && block_given?
|
51
|
+
result
|
52
|
+
else
|
53
|
+
self.current = state_or_lambda
|
54
|
+
end
|
55
|
+
end
|
56
|
+
|
57
|
+
#
|
58
|
+
#
|
59
|
+
def check
|
60
|
+
yield("Whoops. That led nowhere. Perhaps you didn't define the target state?") unless self.current
|
61
|
+
end
|
62
|
+
|
63
|
+
# Returns falsy if it stays the same.
|
64
|
+
#
|
65
|
+
def process phrase, &block
|
66
|
+
exit_text = exit &block
|
67
|
+
last_context = current.context
|
68
|
+
transition phrase, &block
|
69
|
+
check &block
|
70
|
+
into_text = enter &block
|
71
|
+
last_context != current.context
|
72
|
+
end
|
73
|
+
|
74
|
+
#
|
75
|
+
#
|
76
|
+
def hears? phrase
|
77
|
+
expects.include? phrase
|
78
|
+
end
|
79
|
+
|
80
|
+
# Does the current state allow penetration into another dialog?
|
81
|
+
#
|
82
|
+
def chainable?
|
83
|
+
current.chainable?
|
84
|
+
end
|
85
|
+
|
86
|
+
def to_s
|
87
|
+
"#{self.class.name}(#{initial}, current: #{current})"
|
88
|
+
end
|
89
|
+
|
90
|
+
end
|
91
|
+
|
92
|
+
end
|
93
|
+
|
94
|
+
end
|
@@ -0,0 +1,37 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Markers
|
4
|
+
|
5
|
+
# A marker is a point in conversation
|
6
|
+
# where we once were and might go back.
|
7
|
+
#
|
8
|
+
# TODO: Rename to ?.
|
9
|
+
#
|
10
|
+
class Memory < Marker
|
11
|
+
|
12
|
+
# Hear a phrase.
|
13
|
+
#
|
14
|
+
# Returns a new Current if it heard.
|
15
|
+
# Returns itself if not.
|
16
|
+
#
|
17
|
+
def hear phrase, &block
|
18
|
+
return [self] unless hears? phrase
|
19
|
+
last = current
|
20
|
+
process(phrase, &block) ? [Memory.new(last), Current.new(current)] : [Current.new(current)]
|
21
|
+
end
|
22
|
+
|
23
|
+
# A marker does not care about phrases that cross dialog boundaries.
|
24
|
+
#
|
25
|
+
def expects
|
26
|
+
current.internal_expects
|
27
|
+
end
|
28
|
+
|
29
|
+
def current?
|
30
|
+
false
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
end
|
36
|
+
|
37
|
+
end
|
@@ -0,0 +1,31 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Outputs
|
4
|
+
|
5
|
+
class Audio
|
6
|
+
|
7
|
+
# Create a new audio output.
|
8
|
+
#
|
9
|
+
# Options:
|
10
|
+
# * voice # Default is 'com.apple.speech.synthesis.voice.Alex'.
|
11
|
+
#
|
12
|
+
def initialize options = {}
|
13
|
+
@output = NSSpeechSynthesizer.alloc.initWithVoice options[:voice] || 'com.apple.speech.synthesis.voice.Alex'
|
14
|
+
end
|
15
|
+
|
16
|
+
# Say the given text out loud.
|
17
|
+
#
|
18
|
+
# Waits for the last text to be finished
|
19
|
+
#
|
20
|
+
def say text
|
21
|
+
while @output.isSpeaking
|
22
|
+
sleep 0.1
|
23
|
+
end
|
24
|
+
@output.startSpeakingString text
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
28
|
+
|
29
|
+
end
|
30
|
+
|
31
|
+
end
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
module Outputs
|
4
|
+
|
5
|
+
# Terminal output for silent purposes.
|
6
|
+
#
|
7
|
+
class Terminal
|
8
|
+
|
9
|
+
#
|
10
|
+
#
|
11
|
+
def initialize options = {}
|
12
|
+
|
13
|
+
end
|
14
|
+
|
15
|
+
# Say the given text in the terminal.
|
16
|
+
#
|
17
|
+
def say text
|
18
|
+
puts
|
19
|
+
p text
|
20
|
+
puts
|
21
|
+
end
|
22
|
+
|
23
|
+
end
|
24
|
+
|
25
|
+
end
|
26
|
+
|
27
|
+
end
|
@@ -0,0 +1,123 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
# A state is defined in a dialog.
|
4
|
+
#
|
5
|
+
# It has a name with which it can be targeted.
|
6
|
+
#
|
7
|
+
# A state has three methods:
|
8
|
+
# * hear: If this phrase (or one of these phrases) is heard, move to that state. Takes a hash.
|
9
|
+
# * into: A block that is called on entering.
|
10
|
+
# * exit: A block that is called on exit.
|
11
|
+
#
|
12
|
+
# Example:
|
13
|
+
# state :time do
|
14
|
+
# hear ['What time is it?', 'And now?'] => :time
|
15
|
+
# into { time = Time.now; "It is currently #{time.hour} #{time.min}." }
|
16
|
+
# exit { "And that was the time." }
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
class State
|
20
|
+
|
21
|
+
attr_reader :name, :context
|
22
|
+
|
23
|
+
def initialize name, context
|
24
|
+
@name = name
|
25
|
+
@context = context
|
26
|
+
|
27
|
+
@transitions = {}
|
28
|
+
|
29
|
+
instance_eval(&Proc.new) if block_given?
|
30
|
+
end
|
31
|
+
|
32
|
+
# How do I get from this state to another?
|
33
|
+
#
|
34
|
+
# Example:
|
35
|
+
# hear 'What time is it?' => :time,
|
36
|
+
# 'What? This late?' => :yes
|
37
|
+
#
|
38
|
+
# Example for staying in the same state:
|
39
|
+
# hear 'What time is it?' # Implicitly staying.
|
40
|
+
#
|
41
|
+
# Example for staying in the same state and doing something:
|
42
|
+
# hear 'What time is it?' => ->() { "I'm staying in the same state" }
|
43
|
+
#
|
44
|
+
def hear transitions
|
45
|
+
transitions = { transitions => name } unless transitions.respond_to?(:to_hash)
|
46
|
+
@transitions = expand(transitions).merge @transitions
|
47
|
+
end
|
48
|
+
|
49
|
+
# Execute this block or say the text when entering this state.
|
50
|
+
#
|
51
|
+
# Examples:
|
52
|
+
# into "Yes, Sir?"
|
53
|
+
# into { "A random number is #{rand(10)}" }
|
54
|
+
#
|
55
|
+
def into text = nil, &block
|
56
|
+
@into_block = block ||
|
57
|
+
text && lambda { text } ||
|
58
|
+
raise_no_text_or_block(__method__)
|
59
|
+
end
|
60
|
+
|
61
|
+
# Execute this block or say the text when exiting this state.
|
62
|
+
#
|
63
|
+
# Examples:
|
64
|
+
# exit "Yes, Sir?"
|
65
|
+
# exit { "A random number is #{rand(10)}" }
|
66
|
+
#
|
67
|
+
def exit text = nil, &block
|
68
|
+
@exit_block = block ||
|
69
|
+
text && lambda { text } ||
|
70
|
+
raise_no_text_or_block(__method__)
|
71
|
+
end
|
72
|
+
|
73
|
+
# Chain the given dialog to this state.
|
74
|
+
#
|
75
|
+
def << dialog
|
76
|
+
dialog.chain_to self
|
77
|
+
end
|
78
|
+
|
79
|
+
# A chainable state is a state from which other
|
80
|
+
# Dialogs can be reached.
|
81
|
+
#
|
82
|
+
def chainable
|
83
|
+
@chainable = true
|
84
|
+
end
|
85
|
+
|
86
|
+
# Description of self using name and transitions.
|
87
|
+
#
|
88
|
+
def to_s
|
89
|
+
"#{self.class.name}(#{name}, #{context}, #{@transitions})"
|
90
|
+
end
|
91
|
+
|
92
|
+
# The naughty privates of this class.
|
93
|
+
#
|
94
|
+
|
95
|
+
# Expands a hash in the form
|
96
|
+
# * [a, b] => c to a => c, b => c
|
97
|
+
# but leaves a non-array key alone.
|
98
|
+
#
|
99
|
+
def expand transitions
|
100
|
+
results = {}
|
101
|
+
transitions.each_pair do |phrases, state_name|
|
102
|
+
[*phrases].each do |phrase|
|
103
|
+
results[phrase] = state_name
|
104
|
+
end
|
105
|
+
end
|
106
|
+
results
|
107
|
+
end
|
108
|
+
|
109
|
+
# Raise an ArgumentError for the given method if it needs either a text or a block.
|
110
|
+
#
|
111
|
+
def raise_no_text_or_block on_method
|
112
|
+
raise ArgumentError.new("Neither block nor text given to ##{on_method} call in #{caller[1]}.")
|
113
|
+
end
|
114
|
+
|
115
|
+
# By default, a state is not chainable.
|
116
|
+
#
|
117
|
+
def chainable?
|
118
|
+
!!@chainable
|
119
|
+
end
|
120
|
+
|
121
|
+
end
|
122
|
+
|
123
|
+
end
|
@@ -0,0 +1,59 @@
|
|
1
|
+
module James
|
2
|
+
|
3
|
+
class State
|
4
|
+
|
5
|
+
# Transitions are internal transitions & external transitions.
|
6
|
+
#
|
7
|
+
def transitions
|
8
|
+
@transitions
|
9
|
+
end
|
10
|
+
|
11
|
+
#
|
12
|
+
#
|
13
|
+
def expects
|
14
|
+
transitions.keys
|
15
|
+
end
|
16
|
+
|
17
|
+
#
|
18
|
+
#
|
19
|
+
def internal_expects
|
20
|
+
transitions.select { |phrase, target| target.respond_to?(:to_sym) || target == self.context }.keys
|
21
|
+
end
|
22
|
+
|
23
|
+
|
24
|
+
# Returns the next state for the given phrase.
|
25
|
+
#
|
26
|
+
# It accesses the context (aka Dialog) to get a full object state.
|
27
|
+
#
|
28
|
+
# If it is a Symbol, James will try to get the real state.
|
29
|
+
# If not, it will just return it (a State already, or lambda).
|
30
|
+
#
|
31
|
+
def next_for phrase
|
32
|
+
state = self.transitions[phrase]
|
33
|
+
state.respond_to?(:id2name) ? context.state_for(state) : state
|
34
|
+
end
|
35
|
+
|
36
|
+
# The naughty privates.
|
37
|
+
#
|
38
|
+
|
39
|
+
# Called by the visitor visiting this state.
|
40
|
+
#
|
41
|
+
def __into__
|
42
|
+
@into_block && context.instance_eval(&@into_block)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Called by the visitor visiting this state.
|
46
|
+
#
|
47
|
+
def __exit__
|
48
|
+
@exit_block && context.instance_eval(&@exit_block)
|
49
|
+
end
|
50
|
+
|
51
|
+
# Called by the visitor visiting this state.
|
52
|
+
#
|
53
|
+
def __transition__ &block
|
54
|
+
context.instance_eval &block
|
55
|
+
end
|
56
|
+
|
57
|
+
end
|
58
|
+
|
59
|
+
end
|
metadata
ADDED
@@ -0,0 +1,85 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: james
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
prerelease:
|
5
|
+
version: 0.5.0
|
6
|
+
platform: ruby
|
7
|
+
authors:
|
8
|
+
- Florian Hanke
|
9
|
+
autorequire:
|
10
|
+
bindir: bin
|
11
|
+
cert_chain: []
|
12
|
+
|
13
|
+
date: 2011-06-15 00:00:00 +10:00
|
14
|
+
default_executable: james
|
15
|
+
dependencies:
|
16
|
+
- !ruby/object:Gem::Dependency
|
17
|
+
name: rspec
|
18
|
+
prerelease: false
|
19
|
+
requirement: &id001 !ruby/object:Gem::Requirement
|
20
|
+
none: false
|
21
|
+
requirements:
|
22
|
+
- - ">="
|
23
|
+
- !ruby/object:Gem::Version
|
24
|
+
version: "0"
|
25
|
+
type: :development
|
26
|
+
version_requirements: *id001
|
27
|
+
description: Modular Electronic Butler. Using a simple dialog system where you can easily add more dialogs.
|
28
|
+
email: florian.hanke+james@gmail.com
|
29
|
+
executables:
|
30
|
+
- james
|
31
|
+
extensions: []
|
32
|
+
|
33
|
+
extra_rdoc_files: []
|
34
|
+
|
35
|
+
files:
|
36
|
+
- lib/james/builtin/core_dialog.rb
|
37
|
+
- lib/james/controller.rb
|
38
|
+
- lib/james/conversation.rb
|
39
|
+
- lib/james/dialog_api.rb
|
40
|
+
- lib/james/dialog_internals.rb
|
41
|
+
- lib/james/dialogs.rb
|
42
|
+
- lib/james/framework.rb
|
43
|
+
- lib/james/inputs/audio.rb
|
44
|
+
- lib/james/inputs/base.rb
|
45
|
+
- lib/james/inputs/terminal.rb
|
46
|
+
- lib/james/markers/current.rb
|
47
|
+
- lib/james/markers/marker.rb
|
48
|
+
- lib/james/markers/memory.rb
|
49
|
+
- lib/james/outputs/audio.rb
|
50
|
+
- lib/james/outputs/terminal.rb
|
51
|
+
- lib/james/state_api.rb
|
52
|
+
- lib/james/state_internals.rb
|
53
|
+
- lib/james.rb
|
54
|
+
- aux/james/cli.rb
|
55
|
+
- bin/james
|
56
|
+
has_rdoc: true
|
57
|
+
homepage: http://floere.github.com/james
|
58
|
+
licenses: []
|
59
|
+
|
60
|
+
post_install_message:
|
61
|
+
rdoc_options: []
|
62
|
+
|
63
|
+
require_paths:
|
64
|
+
- lib
|
65
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
66
|
+
none: false
|
67
|
+
requirements:
|
68
|
+
- - ">="
|
69
|
+
- !ruby/object:Gem::Version
|
70
|
+
version: "0"
|
71
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
72
|
+
none: false
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: "0"
|
77
|
+
requirements: []
|
78
|
+
|
79
|
+
rubyforge_project:
|
80
|
+
rubygems_version: 1.5.0
|
81
|
+
signing_key:
|
82
|
+
specification_version: 3
|
83
|
+
summary: "James: Modular Electronic Butler with modular Dialogs."
|
84
|
+
test_files: []
|
85
|
+
|