# word-for-word against a clause (same length, no fuzzing - that's Tier 2).
module Tier1
class << self
- attr_accessor :rules
+ attr_accessor :tools
end
def self.configure(ruleset)
- self.rules = ruleset.rule_patterns
+ self.tools = ruleset.tool_patterns
end
# Returns an action node (Hash) on an exact match, or nil on a miss.
def self.lookup(clause)
- rules.each do |pattern, action_name|
- args = match(pattern.split, clause)
- return { type: "action", name: action_name, args: args } if args
+ tools.each do |tool|
+ tool.rules.each do |pattern|
+ args = match(pattern.split, clause)
+ return { type: "action", name: tool.name, args: args, tool: tool } if args
+ end
end
nil
end
end
module Ruleset
+ # One tool: the rule patterns that trigger it, the keyword-arg action that
+ # runs it, and whether running it needs user confirmation first.
+ Tool = Struct.new(:name, :rules, :action, :permission)
+
+ # Collects rule()/action()/permission() calls inside a tool(...) do...end
+ # block, then validates them into a Tool on build.
+ class ToolBuilder
+ PERMISSIONS = %i[yolo ask].freeze
+
+ def initialize(name)
+ @name = name
+ @patterns = []
+ @permission = :yolo
+ @action = nil
+ end
+
+ def rule(pattern)
+ # The leading word anchors verb detection (SS verb_patterns below); a
+ # rule can't open on a :slot.
+ raise ArgumentError, "tool #{@name.inspect}: rule #{pattern.inspect} must start with a literal word" if pattern.split.first.start_with?(":")
+
+ @patterns << pattern
+ end
+
+ def permission(level)
+ raise ArgumentError, "tool #{@name.inspect}: unknown permission #{level.inspect}" unless PERMISSIONS.include?(level)
+
+ @permission = level
+ end
+
+ def action(&block)
+ @action = block
+ end
+
+ def build
+ raise ArgumentError, "tool #{@name.inspect} has no rule" if @patterns.empty?
+ raise ArgumentError, "tool #{@name.inspect} has no action" unless @action
+
+ slots = slot_sets
+ raise ArgumentError, "tool #{@name.inspect}: rules disagree on slot names" unless slots.uniq.length == 1
+ raise ArgumentError, "tool #{@name.inspect}: action params don't match rule slots" unless slots.first == required_keywords
+
+ Tool.new(@name, @patterns, @action, @permission)
+ end
+
+ private
+
+ # Each rule's slot names (the ":target" in "copy :target to :destination"),
+ # so build() can confirm every rule in this tool agrees on them.
+ def slot_sets
+ @patterns.map do |pattern|
+ pattern.split.each_with_object(Set.new) { |w, s| s << w.delete_prefix(":").to_sym if w.start_with?(":") }
+ end
+ end
+
+ def required_keywords
+ @action.parameters.select { |type, _| type == :keyreq }.map { |_, name| name }.to_set
+ end
+ end
+
class RulesModule
+ def initialize
+ @verbs = Set.new
+ @tools = []
+ end
+
def types(hash)
@types = hash
end
@classifiers = hash
end
- def rules(hash)
- @rules = hash
+ def tool(name, &block)
+ builder = ToolBuilder.new(name)
+ builder.instance_eval(&block)
+ @tools << builder.build
end
def verbs(list)
def literal_patterns = @types
def connective_patterns = @connectives
- def rule_patterns = @rules
- def verb_patterns = @verbs
+ def tool_patterns = @tools
+
+ # Every rule's leading word is a verb by construction (ToolBuilder
+ # rejects a rule that opens on a :slot), plus any verbs() declared for
+ # words not yet backed by a tool - so chaining still recognizes them.
+ def verb_patterns
+ @verbs | @tools.flat_map { |t| t.rules.map { |r| r.split.first } }
+ end
# :action phrases come from verbs, not the classifiers() call, so the
# two lists can't drift apart.
- def classifier_patterns = @classifiers.merge(action: @verbs.to_a.map { |v| [v] })
+ def classifier_patterns = @classifiers.merge(action: verb_patterns.to_a.map { |v| [v] })
end
def self.create(name, &block)
# action: derived from verbs, below.
)
- rules(
- "delete :target" => "delete",
- "remove :target" => "delete",
- "archive :target" => "archive",
- "copy :target to :destination" => "copy",
- "move :target" => "move",
- )
+ tool("delete") do
+ rule "delete :target"
+ rule "remove :target"
+
+ permission :ask
+
+ action do |target:|
+ puts "Deleting #{target}"
+ end
+ end
+
+ tool("archive") do
+ rule "archive :target"
+
+ action do |target:|
+ puts "Archiving #{target}"
+ end
+ end
+
+ tool("copy-from-to") do
+ rule "copy :target to :destination"
- # Verbs recognized by connective_length's bare "and <verb>" check.
- verbs %w[run delete remove copy move create rename archive search find locate pick transfer]
+ action do |target:, destination:|
+ puts "Copying from #{target} to #{destination}"
+ end
+ end
+
+ tool("move") do
+ rule "move :target"
+
+ permission :ask
+
+ action do |target:|
+ puts "Moving #{target}"
+ end
+ end
+
+ # Verbs not yet backed by a tool; "and <verb>" chaining still recognizes them.
+ verbs %w[run create rename search find locate pick transfer]
end
Lexer.configure(builtins)
node = Tier1.lookup(clause.tokens)
puts " #{node ? "tier1 hit: #{node}" : 'tier1 miss'}"
+ next unless node
+
+ tool = node[:tool]
+ # :ask tools must be confirmed before running; :yolo tools run immediately.
+ if tool.permission == :ask
+ print " run #{tool.name}? [y/N] "
+ next unless gets&.chomp&.downcase == "y"
+ end
+
+ tool.action.call(**node[:args].transform_values(&:text))
end
puts "match: #{Lexer.match_text(utterance.clauses.flat_map(&:tokens))}"