james 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
|