voice_control 0.1.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.
@@ -0,0 +1,72 @@
1
+ VoiceControl.configure do |config|
2
+ # Server-side Jev API key; accepts a string or a zero-argument callable.
3
+ config.api_key = ENV["JEV_API_KEY"]
4
+
5
+ # Jev model used to match natural-language commands to your vocabulary.
6
+ config.model = "jev-latest"
7
+
8
+ # Engine endpoints inherit this controller's authentication and callbacks.
9
+ config.parent_controller = "ApplicationController"
10
+
11
+ # Controls access to the widget and endpoints. Replace with your own access check.
12
+ # For example: -> { current_user&.admin? }. Access is denied until configured.
13
+ config.authorize = -> { false }
14
+
15
+ # Binds execution tickets to an account as well as its session; defaults to current_user.id when available.
16
+ # Override this for another authentication system.
17
+ # config.identity = -> { current_account.id }
18
+
19
+ # Filters browser-supplied context before matching; defaults to passing it through.
20
+ # Keep only the keys your commands need, especially if URLs contain private query parameters.
21
+ # config.context = ->(client_context) { client_context.slice("path") }
22
+
23
+ # Discovers labeled page controls for click, fill, select, check, scroll and submit commands.
24
+ # Sends control labels/IDs and dropdown labels to Jev; existing field values are excluded.
25
+ config.browser_actions = false
26
+
27
+ # Open/close shortcut; mod is Cmd on macOS and Ctrl elsewhere. Set nil to disable.
28
+ config.keyboard_shortcut = "mod+shift+u"
29
+
30
+ # Hold to speak, release to submit. Set nil to disable this shortcut.
31
+ config.push_to_talk_shortcut = "mod+shift+space"
32
+
33
+ # Speech recognition language as a BCP 47 tag, e.g. "uk-UA". Widget text and built-in page phrases stay English.
34
+ config.speech_language = "en-US"
35
+
36
+ # Corner for the launcher and panel: :bottom_right or :bottom_left.
37
+ config.widget_position = :bottom_right
38
+
39
+ # :small uses a 44px button and 20px icon; :normal uses 56px and 24px. Panel width is unchanged.
40
+ config.launcher_size = :normal
41
+
42
+ # Close and stop listening after this many milliseconds of inactivity; use a positive number.
43
+ config.idle_timeout = 120_000
44
+
45
+ # Maximum milliseconds per request, including reading its response; execution timeouts never retry automatically.
46
+ config.request_timeout = 30_000
47
+
48
+ # Matches below this confidence ask for disambiguation. Calibrate for your vocabulary.
49
+ config.confidence_threshold = 0.35
50
+
51
+ # Shows the latest transcript, matching details, Jev result and execution timing in the widget.
52
+ # Enable for trusted users while troubleshooting; copied details may contain private data.
53
+ config.debug = false
54
+
55
+ # Defaults to Rails.cache, with a MemoryStore fallback for development NullStore.
56
+ # Production needs a shared atomic cache for replay protection; see docs/deployment.md.
57
+ # config.execution_store = -> { Rails.cache }
58
+
59
+ # Optional matcher accepting transcript:, context:, commands:; nil uses Jev.
60
+ # Return command:, confidence:, candidates: without executing an action; see docs/configuration.md.
61
+ config.interpreter = nil
62
+
63
+ # Receives unexpected errors and command/controller details; the UI shows a generic message.
64
+ # The default logs only the error class and command key. Replace with your error tracker if needed.
65
+ # config.on_error = ->(error, details) { Rails.logger.error("VoiceControl #{error.class} command=#{details[:command]}") }
66
+
67
+ config.group "Navigation" do
68
+ config.command :home, description: "Open home", aliases: ["go home"], examples: ["open home"] do
69
+ execute { |_args, _context| VoiceControl::Result.navigate(main_app.root_path) }
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,59 @@
1
+ require "bigdecimal"
2
+
3
+ module VoiceControl
4
+ class Argument
5
+ TYPES = [:string, :integer, :decimal, :boolean, :enum].freeze
6
+ attr_reader :name, :type, :prompt
7
+
8
+ def initialize(name, type, required: true, default: nil, extract: nil, validate: nil, values: nil, prompt: nil)
9
+ raise ArgumentError, "Unknown argument type: #{type}" unless TYPES.include?(type)
10
+ raise ArgumentError, "Enum requires values" if type == :enum && (values.nil? || values.empty?)
11
+
12
+ @name, @type, @required = name.to_s, type, required
13
+ @default, @extractor, @validator, @values = default, extract, validate, values&.map(&:to_s)
14
+ @prompt = prompt || "What is the #{name.to_s.tr('_', ' ')}?#{@values ? " Choose: #{@values.join(', ')}." : ''}"
15
+ end
16
+
17
+ def initial_value(controller, transcript, context)
18
+ value = controller.instance_exec(transcript, context, &@extractor) if @extractor
19
+ if value.nil?
20
+ value = @default.respond_to?(:call) ? controller.instance_exec(transcript, context, &@default) : @default
21
+ end
22
+ value
23
+ end
24
+
25
+ def coerce(value, controller:)
26
+ return nil if value.nil? && !@required
27
+ raise InvalidInput, prompt if value.nil? || value.to_s.strip.empty? || value.to_s.length > 2_000
28
+
29
+ text = value.to_s.strip
30
+ result = case type
31
+ when :integer
32
+ raise ArgumentError unless text.match?(/\A[+-]?\d+\z/)
33
+ Integer(text, 10)
34
+ when :decimal
35
+ raise ArgumentError unless text.match?(/\A[+-]?\d+(?:\.\d+)?\z/)
36
+ BigDecimal(text).to_s("F")
37
+ when :boolean
38
+ case text.downcase
39
+ when "true", "yes", "on" then true
40
+ when "false", "no", "off" then false
41
+ else raise ArgumentError
42
+ end
43
+ when :enum
44
+ @values.find { |option| option.tr("_", " ").casecmp?(text.tr("_", " ")) } || (raise ArgumentError)
45
+ else
46
+ text
47
+ end
48
+ raise ArgumentError if @validator && !controller.instance_exec(result, &@validator)
49
+
50
+ result
51
+ rescue ArgumentError, TypeError
52
+ raise InvalidInput, prompt
53
+ end
54
+
55
+ def as_json(*)
56
+ { name: name, type: type, required: @required, values: @values, prompt: prompt }.compact
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,210 @@
1
+ module VoiceControl
2
+ class BrowserActions
3
+ MAX_TARGETS = 200
4
+ PREFIX = "voice_control_browser_".freeze
5
+ SCROLL_DIRECTIONS = %w[up down top bottom].freeze
6
+ HISTORY_DIRECTIONS = %w[back forward].freeze
7
+ attr_reader :commands
8
+
9
+ def initialize(page)
10
+ unless page.is_a?(Hash) && JSON.generate(page).bytesize <= 131_072 &&
11
+ page["page_id"].is_a?(String) && page["page_id"].match?(/\A[a-z0-9-]{1,64}\z/i) &&
12
+ page["elements"].is_a?(Array) && page["elements"].length <= MAX_TARGETS
13
+ raise InvalidInput, "Invalid browser controls. Reload the page and try again."
14
+ end
15
+
16
+ @page_id = page["page_id"]
17
+ @targets = page["elements"].map { |target| validate_target(target) }
18
+ raise InvalidInput, "Duplicate browser controls" unless @targets.map { |target| target["ref"] }.uniq.length == @targets.length
19
+
20
+ @selected_ref = page["selected_ref"]
21
+ if @selected_ref && !@targets.any? { |target| target["ref"] == @selected_ref && (target["actions"] & %w[fill select]).any? }
22
+ raise InvalidInput, "Select an editable field first."
23
+ end
24
+
25
+ @command_targets = {}
26
+ @commands = @targets.flat_map { |target| build_commands(target) } + build_scroll_commands + build_history_commands + [build_submit_command]
27
+ end
28
+
29
+ def snapshot(keys)
30
+ refs = keys.filter_map { |key| @command_targets[key] }
31
+ return if refs.empty? && keys.none? { |key|
32
+ key == "#{PREFIX}submit" || SCROLL_DIRECTIONS.any? { |direction| key == "#{PREFIX}scroll_#{direction}" } ||
33
+ HISTORY_DIRECTIONS.any? { |direction| key == "#{PREFIX}history_#{direction}" }
34
+ }
35
+
36
+ { "page_id" => @page_id, "elements" => @targets.select { |target| refs.include?(target["ref"]) },
37
+ "selected_ref" => (@selected_ref if refs.include?(@selected_ref)) }.compact
38
+ end
39
+
40
+ def exact_click_matches(transcript)
41
+ phrase = transcript.squish.downcase
42
+ @commands.select do |command|
43
+ command.key.start_with?("#{PREFIX}click_") &&
44
+ command.aliases.any? { |name| name.start_with?("click ") && name.squish.downcase == phrase }
45
+ end
46
+ end
47
+
48
+ class << self
49
+ def extract_value(text, label:)
50
+ text[/["“](.+?)["”]/, 1] ||
51
+ text[/\A(?:fill|enter|type|set)\s+(?:the\s+)?#{Regexp.escape(label)}(?:\s+field)?\s+(?:with|to)\s+(.+)\z/i, 1] ||
52
+ text[/\b(?:fill|set)\b.+?\b(?:with|to)\s+(.+)\z/i, 1] ||
53
+ text[/\b(?:enter|type)\s+(.+?)\s+(?:in|into)\s+.+\z/i, 1]
54
+ end
55
+
56
+ def selected_value(text)
57
+ text[/\A(?:enter|type)\s+(.+)\z/im, 1]&.sub(/\A"(.*)"\z/m, '\1')&.sub(/\A“(.*)”\z/m, '\1')
58
+ end
59
+
60
+ def select_value(text, label:)
61
+ extract_value(text, label: label) ||
62
+ text[/\A(?:select|choose)\s+(.+?)\s+(?:from|in|for)\s+(?:the\s+)?#{Regexp.escape(label)}\z/i, 1] ||
63
+ (text[/\A(?:select|choose)\s+(.+)\z/i, 1] unless text.match?(/\A(?:select|choose)\s+#{Regexp.escape(label)}\z/i))
64
+ end
65
+ end
66
+
67
+ private
68
+
69
+ def validate_target(target)
70
+ unless target.is_a?(Hash) && target["ref"].is_a?(String) && target["ref"].match?(/\Ae\d{1,9}\z/)
71
+ raise InvalidInput, "Invalid browser control reference"
72
+ end
73
+ clean = target.slice("ref", "label", "id", "name", "tag", "type")
74
+ unless %w[label id name tag type].all? { |key| clean[key].is_a?(String) && clean[key].length <= 160 } && clean["label"].present?
75
+ raise InvalidInput, "Invalid browser control label"
76
+ end
77
+ allowed = case clean["tag"]
78
+ when "button", "a" then %w[click]
79
+ when "textarea" then %w[fill focus clear]
80
+ when "h1", "h2", "h3", "h4", "h5", "h6" then %w[reveal]
81
+ when "select"
82
+ options = target["options"]
83
+ unless options.is_a?(Array) && options.length.between?(1, 100) && JSON.generate(options).bytesize <= 4096 &&
84
+ options.all? { |option| option.is_a?(Hash) && option["ref"].is_a?(String) && option["ref"].match?(/\Ao\d{1,6}\z/) && option["label"].is_a?(String) && option["label"].strip.length.between?(1, 160) }
85
+ raise InvalidInput, "Invalid dropdown options"
86
+ end
87
+ clean["options"] = options.map { |option| option.slice("ref", "label") }
88
+ if options.map { |option| option["ref"] }.uniq.length != options.length ||
89
+ options.map { |option| option["label"].tr("_", " ").downcase }.uniq.length != options.length
90
+ raise InvalidInput, "Dropdown options must have distinct labels."
91
+ end
92
+ %w[select focus]
93
+ when "input"
94
+ if clean["type"] == "checkbox"
95
+ %w[check uncheck]
96
+ elsif clean["type"] == "radio"
97
+ %w[choose]
98
+ elsif %w[button submit reset].include?(clean["type"])
99
+ %w[click]
100
+ elsif %w[text search email tel url number date time].include?(clean["type"])
101
+ %w[fill focus clear]
102
+ else
103
+ []
104
+ end
105
+ else
106
+ clean["type"] == "button" ? %w[click] : []
107
+ end
108
+ raise InvalidInput, "Unsupported browser control" if allowed.empty?
109
+
110
+ clean.merge("actions" => allowed)
111
+ end
112
+
113
+ def build_commands(target)
114
+ actions = target["actions"] + (target["ref"] == @selected_ref ? ["enter"] : [])
115
+ actions << "clear_selected" if target["ref"] == @selected_ref && target["actions"].include?("clear")
116
+ actions.map do |action|
117
+ selected = %w[enter clear_selected].include?(action)
118
+ fill = %w[fill enter].include?(action)
119
+ key = "#{PREFIX}#{action}_#{target['ref']}"
120
+ @command_targets[key] = target["ref"]
121
+ next build_checked_command(target, key, action) if %w[check uncheck choose].include?(action)
122
+ next build_dropdown_command(target, key, selected: selected) if action == "select" || (selected && target["tag"] == "select")
123
+ label = target["label"]
124
+ identity = [target["tag"], ("id: #{target['id']}" if target["id"].present?), ("name: #{target['name']}" if target["name"].present?)].compact.join(", ")
125
+ result_action = { "enter" => "fill", "clear_selected" => "clear" }.fetch(action, action)
126
+ result = { kind: "browser", action: result_action, target: target["ref"], page_id: @page_id }
127
+ result[:selected] = true if selected
128
+ aliases = (action == "fill" ? %w[fill enter type set] : [action]).map { |verb| "#{verb} #{label}" }
129
+ aliases << label if action == "click" && label.match?(/\A(?:edit|delete|remove|view|open|manage)\s/i)
130
+ aliases = ["clear this field", "clear selected field", "clear the selected field"] if action == "clear_selected"
131
+ aliases = ["show #{label}", "show the #{label} section", "scroll to #{label}", "go to #{label} section"] if action == "reveal"
132
+ description = if selected
133
+ "#{action == 'enter' ? 'Enter a value into' : 'Clear'} the selected field: #{label}"
134
+ elsif action == "reveal"
135
+ "Show #{label} section"
136
+ else
137
+ "#{action.capitalize} #{label} (#{identity})"
138
+ end
139
+ Command.new(key, description: description, group: "On this page",
140
+ aliases: action == "enter" ? %w[enter type] : aliases, examples: []) do
141
+ if fill
142
+ argument :value, :string, prompt: "What should I enter in #{label}?",
143
+ extract: ->(text, _context) { selected ? BrowserActions.selected_value(text) : BrowserActions.extract_value(text, label: label) }
144
+ end
145
+ execute { |args, _context| fill ? result.merge(value: args[:value]) : result }
146
+ end
147
+ end
148
+ end
149
+
150
+ def build_dropdown_command(target, key, selected:)
151
+ page_id = @page_id
152
+ label = target["label"]
153
+ options = target["options"]
154
+ values = options.map { |option| option["label"] }
155
+ aliases = selected ? %w[enter type] : ["set #{label}", "select #{label}", "choose #{label}"] + values.flat_map { |value| ["select #{value}", "choose #{value}"] }
156
+ description = selected ? "Enter a choice into the selected dropdown: #{label}" : "Select #{label}"
157
+ Command.new(key, description: description, group: "On this page", aliases: aliases) do
158
+ argument :value, :enum, values: values,
159
+ prompt: "Which #{label}? Choose: #{values.join(', ')}.",
160
+ extract: ->(text, _context) { selected ? BrowserActions.selected_value(text) : BrowserActions.select_value(text, label: label) }
161
+ execute do |args, _context|
162
+ result = { kind: "browser", action: "select", target: target["ref"], page_id: page_id,
163
+ option: options.find { |option| option["label"] == args[:value] }.fetch("ref") }
164
+ result[:selected] = true if selected
165
+ result
166
+ end
167
+ end
168
+ end
169
+
170
+ def build_checked_command(target, key, action)
171
+ label = target["label"]
172
+ subject = label.sub(/\A(?:enable|disable)\s+/i, "")
173
+ verbs = { "check" => ["check", "enable", "turn on"], "uncheck" => ["uncheck", "disable", "turn off"], "choose" => ["choose", "select", "check"] }.fetch(action)
174
+ aliases = verbs.product([label, subject].uniq).map { |verb, name| "#{verb} #{name}" }
175
+ result = { kind: "browser", action: action, target: target["ref"], page_id: @page_id }
176
+ Command.new(key, description: "#{action.capitalize} #{label}", group: "On this page", aliases: aliases) do
177
+ execute { |_args, _context| result }
178
+ end
179
+ end
180
+
181
+ def build_scroll_commands
182
+ page_id = @page_id
183
+ SCROLL_DIRECTIONS.map do |direction|
184
+ aliases = ["scroll #{direction}", "scroll to #{direction}", "scroll to the #{direction}"]
185
+ aliases += ["back to top", "go to top"] if direction == "top"
186
+ Command.new("#{PREFIX}scroll_#{direction}", description: "Scroll #{direction}", group: "On this page", aliases: aliases) do
187
+ execute { |_args, _context| { kind: "browser", action: "scroll", direction: direction, page_id: page_id } }
188
+ end
189
+ end
190
+ end
191
+
192
+ def build_history_commands
193
+ page_id = @page_id
194
+ HISTORY_DIRECTIONS.map do |direction|
195
+ Command.new("#{PREFIX}history_#{direction}", description: "Go #{direction}", group: "On this page",
196
+ aliases: ["go #{direction}", "browser #{direction}", direction]) do
197
+ execute { |_args, _context| { kind: "browser", action: "history", direction: direction, page_id: page_id } }
198
+ end
199
+ end
200
+ end
201
+
202
+ def build_submit_command
203
+ page_id = @page_id
204
+ Command.new("#{PREFIX}submit", description: "Submit active form", group: "On this page",
205
+ aliases: ["submit", "submit form", "submit this form", "submit active form", "submit this field"]) do
206
+ execute { |_args, _context| { kind: "browser", action: "submit", page_id: page_id } }
207
+ end
208
+ end
209
+ end
210
+ end
@@ -0,0 +1,51 @@
1
+ module VoiceControl
2
+ class Command
3
+ attr_reader :key, :description, :group, :aliases, :examples, :arguments, :executor
4
+
5
+ def initialize(key, description:, group:, aliases: [], examples: [], pages: nil, visible: -> { true }, authorize: ->(_args, _context) { true }, &block)
6
+ @key, @description, @group = key, description, group
7
+ @aliases, @examples = aliases, examples
8
+ @visibility, @authorization = visible, authorize
9
+ @pages = pages.nil? ? nil : Array(pages)
10
+ if @pages && (@pages.empty? || !@pages.all? { |page| page.is_a?(Regexp) || local_path?(page) })
11
+ raise ArgumentError, "Command pages must be local paths or regular expressions"
12
+ end
13
+ @arguments = []
14
+ instance_eval(&block)
15
+ raise ArgumentError, "Command #{key} needs an execute block" unless executor
16
+ end
17
+
18
+ def argument(name, type, **options)
19
+ raise ArgumentError, "Duplicate argument: #{name}" if arguments.any? { |argument| argument.name == name.to_s }
20
+
21
+ arguments << Argument.new(name, type, **options)
22
+ end
23
+
24
+ def execute(&block)
25
+ @executor = block
26
+ end
27
+
28
+ def visible?(controller)
29
+ !!controller.instance_exec(&@visibility)
30
+ end
31
+
32
+ def available_on?(path)
33
+ !@pages || (local_path?(path) && @pages.any? { |page| page.is_a?(Regexp) ? page.match?(path) : page == path })
34
+ end
35
+
36
+ def allowed?(controller, args, context)
37
+ visible?(controller) && !!controller.instance_exec(args, context, &@authorization)
38
+ end
39
+
40
+ def as_json(*)
41
+ { key: key, description: description, group: group, aliases: aliases, examples: examples,
42
+ arguments: arguments.map(&:as_json) }
43
+ end
44
+
45
+ private
46
+
47
+ def local_path?(path)
48
+ path.is_a?(String) && path.length <= 2_048 && path.match?(%r{\A/(?!/)[^?#\s\\]*\z})
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,71 @@
1
+ module VoiceControl
2
+ class Configuration
3
+ attr_accessor :api_key, :model, :parent_controller, :authorize, :context,
4
+ :on_error, :interpreter, :keyboard_shortcut, :idle_timeout, :confidence_threshold,
5
+ :execution_store, :identity, :browser_actions, :debug, :push_to_talk_shortcut
6
+ attr_reader :commands, :widget_position, :launcher_size, :request_timeout, :speech_language
7
+
8
+ def initialize
9
+ @model = "jev-latest"
10
+ @parent_controller = "ApplicationController"
11
+ @authorize = -> { false }
12
+ @identity = -> { respond_to?(:current_user, true) ? current_user&.id : nil }
13
+ @context = ->(client_context) { client_context }
14
+ @on_error = ->(error, details) { Rails.logger.error("VoiceControl #{error.class} command=#{details[:command]}") }
15
+ @keyboard_shortcut = "mod+shift+u"
16
+ @idle_timeout = 120_000
17
+ @request_timeout = 30_000
18
+ @confidence_threshold = 0.35
19
+ development_store = ActiveSupport::Cache::MemoryStore.new if Rails.env.development?
20
+ @execution_store = -> { Rails.cache.is_a?(ActiveSupport::Cache::NullStore) && development_store ? development_store : Rails.cache }
21
+ @commands = {}
22
+ @browser_actions = false
23
+ @debug = false
24
+ @widget_position = :bottom_right
25
+ @launcher_size = :normal
26
+ @push_to_talk_shortcut = "mod+shift+space"
27
+ @speech_language = "en-US"
28
+ end
29
+
30
+ def speech_language=(value)
31
+ raise ArgumentError, "Speech language must be a BCP 47 tag such as en-US or uk-UA" unless value.is_a?(String) && value.match?(/\A[a-z]{2,3}(?:-[a-z0-9]{1,8})*\z/i)
32
+
33
+ @speech_language = value
34
+ end
35
+
36
+ def widget_position=(value)
37
+ raise ArgumentError, "Widget position must be bottom_right or bottom_left" unless %w[bottom_right bottom_left].include?(value.to_s)
38
+
39
+ @widget_position = value.to_sym
40
+ end
41
+
42
+ def request_timeout=(value)
43
+ raise ArgumentError, "Request timeout must be an integer from 1,000 to 300,000 milliseconds" unless value.is_a?(Integer) && value.between?(1_000, 300_000)
44
+
45
+ @request_timeout = value
46
+ end
47
+
48
+ def launcher_size=(value)
49
+ raise ArgumentError, "Launcher size must be normal or small" unless %w[normal small].include?(value.to_s)
50
+
51
+ @launcher_size = value.to_sym
52
+ end
53
+
54
+ def group(name, &block)
55
+ previous = @group
56
+ @group = name.to_s
57
+ block.call(self)
58
+ ensure
59
+ @group = previous
60
+ end
61
+
62
+ def command(key, **options, &block)
63
+ key = key.to_s
64
+ raise ArgumentError, "Duplicate command: #{key}" if commands.key?(key)
65
+ raise ArgumentError, "Invalid command key" unless key.match?(/\A[a-z][a-z0-9_]*\z/) && key != "none"
66
+ raise ArgumentError, "Reserved browser command key" if key.start_with?(BrowserActions::PREFIX)
67
+
68
+ commands[key] = Command.new(key, group: @group || "Commands", **options, &block)
69
+ end
70
+ end
71
+ end