A clever horse
Theory and applications

Christian Lawson-Perfect

image/svg+xml MAGIC

from parsimonious.grammar import Grammar
from parsimonious.nodes import NodeVisitor,rule
import math

from parsimonious.grammar import Grammar

grammar = Grammar("""
        Expression
         = ("clever hans "/"") question 
         
        question
         = question_what / question_love
         
        question_what = ("what is"/"what's"/"can you tell me"/"") " "? terms
        question_love = "i love you" space "hans"?

        terms
         = op_add_subtract

        op_add_subtract
         =  op_add_subtract_combo
         /  op_multiply_divide

        op_add_subtract_combo = op_multiply_divide (space (add/subtract) space op_multiply_divide)+

        op_multiply_divide
         =  op_multiply_divide_combo
         /  op_power

        op_multiply_divide_combo = op_power (space (multiply/divide) space op_power)+

        op_power
         = op_power_combo
         / atom

        op_power_combo = (atom space power space)+ atom

        atom
         =  atom_unary
         /  atom_square_root
         /  atom_cube_root
         /  atom_root
         /  atom_function
         /  number

        atom_unary = number space unaryop
        atom_square_root = (("the "? "square root" " of"?)/"root"/"route"/"√") space number
        atom_cube_root = (("the "? "cube root" " of"?)) space number
        atom_root  = "the "? ordinal space "root of" space number
        atom_function = (gcd/lcm) space terms space "and" space terms

        unaryop
            = "squared"
            / "cubed"
            / "factorial"

        binaryop
            = add 
            / multiply 
            / subtract
            / divide
            / power
         
        add = ("+"/"add"/"plus") 
        multiply = ("×"/"x"/"times"/"multiplied by") 
        subtract = ("-"/"minus"/"take away"/"takeaway"/"take-away"/"subtract") 
        divide = ("÷"/"divided by"/"over") 
        power = ("^"/"to the power of"/"to the") 

        gcd = "the "? ("greatest common factor of"/"greatest common divisor of"/"gcd of"/"gcf of"/("biggest number that divides" " both"?))
        lcm = "the "? ("least common multiple of"/"lcm of"/("smallest multiple of" " both"?))

        space = (" "*)

        number
         = digit / number_word
         
        number_word
         = "one" 
         / ("two"/"to"/"too") 
         / "three" 
         / ("four"/"for") 
         / "five" 
         / "six" 
         / "seven" 
         / ("eight"/"ate") 
         / "nine" 
         / "zero" 

        ordinal
         = ordinal_digit / ordinal_word

        ordinal_digit
         = digit ("st"/"nd"/"rd"/"th")

        ordinal_word
         = "first"
         / "second"
         / "third"
         / "fourth"
         / "fifth"
         / "sixth"
         / "seventh"
         / "eighth"
         / "ninth"
         / "tenth"
         
        digit = ~r"[0-9]+"

""")
ops = {
    '+': lambda a,b:a+b,
    '-': lambda a,b:a-b,
    '*': lambda a,b:a*b,
    '/': lambda a,b:a/b,
    '^': lambda a,b:a**b,
    'squared': lambda a:a*a,
}

unary_ops = {
    'squared': lambda a:a*a,
    'cubed': lambda a:a*a*a,
    'factorial': math.factorial,
}

def gcd(a,b):
    a,b = int(a),int(b)
    if a<1 or b<1:
        raise Exception("gcd of non-positive numbers")
    if a<b:
        return gcd(b,a)
    while b!=0:
        a,b = b,a%b
    return a

def lcm(a,b):
    return a*b//gcd(a,b)

function_ops = {
    'gcd': gcd,
    'lcm': lcm
}

class HansVisitor(NodeVisitor):
    grammar = grammar

    def visit_space(self,*args):
        pass
    
    def visit_digit(self,node,visited_children):
        return int(node.text)
    
    def visit_number_word(self,node,visited_children):
        d = {
            'one': 1,
            'two': 2, 'to': 2, 'too': 2,
            'three': 3,
            'four': 4, 'for': 4,
            'five': 5,
            'six': 6,
            'seven': 7,
            'eight': 8, 'ate': 8,
            'nine': 9,
            'zero': 0
        }
        return d[node.text]
    
    def visit_number(self,node,visited_children):
        return visited_children[0]

    visit_ordinal = NodeVisitor.lift_child

    def visit_ordinal_digit(self,node,visited_children):
        return visited_children[0]

    def visit_ordinal_word(self,node,visited_children):
        d = {
            'first': 1,
            'second': 2,
            'third': 3,
            'fourth': 4,
            'fifth': 5,
            'sixth': 6,
            'seventh': 7,
            'eighth': 8,
            'ninth': 9,
            'tenth': 10,
        }
        return d[node.text]
    
    def visit_add(self,*args):
        return '+'

    def visit_multiply(self,*args):
        return '*'
    
    def visit_subtract(self,*args):
        return '-'
    
    def visit_divide(self,*args):
        return '/'
    
    def visit_power(self,*args):
        return '^'

    def generic_visit(self,node,visited_children):
        if not node.expr_name:
            return visited_children
        return 'g '+node.expr_name

    visit_op_add_subtract = NodeVisitor.lift_child
    visit_op_multiply_divide = NodeVisitor.lift_child
    visit_op_power = NodeVisitor.lift_child
    visit_atom = NodeVisitor.lift_child

    def visit_op_add_subtract_combo(self,node,visited_children):
        a = visited_children[0]
        for bit in visited_children[1]:
            _,op,_,b = bit
            op = op[0]
            a = ops[op](a,b)
        return a

    visit_op_multiply_divide_combo = visit_op_add_subtract_combo

    def visit_op_power_combo(self,node,visited_children):
        a = visited_children[1]
        for bit in visited_children[0][::-1]:
            b,_,op,_ = bit
            op = op[0]
            a = ops[op](b,a)
        return a

    def visit_op_all_squared(self,node,visited_children):
        return ('squared',None)

    def visit_unaryop(self,node,visited_children):
        return node.text

    def visit_atom_unary(self,node,visited_children):
        n,_,op = visited_children
        return unary_ops[op](n)

    def visit_atom_square_root(self,node,visited_children):
        n = visited_children[-1]
        return math.sqrt(n)

    def visit_atom_cube_root(self,node,visited_children):
        n = visited_children[-1]
        return math.pow(n,1/3)

    def visit_atom_root(self,node,visited_children):
        _,r,_,_,_,n = visited_children
        return math.pow(n,1/r)

    def visit_gcd(self,node,visited_children):
        return 'gcd'
    def visit_lcm(self,node,visited_children):
        return 'lcm'

    def visit_atom_function(self,node,visited_children):
        op,_,a,_,_,_,b = visited_children
        op = op[0]
        return function_ops[op](a,b)

    def visit_question_what(self,node,visited_children):
        return ('what',visited_children[-1])
    
    def visit_question_love(self,*args):
        return ('love',True)

    def visit_question(self,node,visited_children):
        return visited_children[0]
    
    def visit_Expression(self,node,visited_children):
        return visited_children[-1]
    
if __name__ == '__main__':
    import sys
    text = sys.argv[1]
    visitor = HansVisitor()
    result = visitor.parse(text)
    print(': ',result)
                    
from grammar import HansVisitor
from parsimonious.exceptions import ParseError
import speech_recognition as sr

from gpiozero import LED, Button
from time import sleep

class Horse(object):

    visitor = HansVisitor()

    neigh_switch = LED(27)
    clop_switch = LED(17)

    listen_button = Button(22)

    def __init__(self, listen_continuously=True):
        self.recognizer = sr.Recognizer()
        self.mic = sr.Microphone(device_index=2,sample_rate=44100,chunk_size=128)
        with self.mic as source:
            self.recognizer.adjust_for_ambient_noise(source)

        if listen_continuously:
            self.start_listening()
        else:
            self.wait_for_button()

    def start_listening(self):
        print("Listening continuously")
        def handle(recognizer,audio):
            self.handle_phrase(recognizer,audio)
        self.stop_listening = self.recognizer.listen_in_background(self.mic, handle)

    def wait_for_button(self):
        print("Listening when button pressed")
        def do_listen():
            print("Listening...")
            with self.mic as source:
                audio = self.recognizer.listen(self.mic)
            self.handle_phrase(self.recognizer,audio)
        self.listen_button.when_pressed = do_listen

    def neigh(self):
        self.neigh_switch.on()
        sleep(2)
        self.neigh_switch.off()

    def clop(self,n):
        self.clop_switch.on()
        sleep(n/2)
        self.clop_switch.off()

    def handle_phrase(self,recognizer,audio):
        print("GOT")
        try:
            text = self.recognizer.recognize_google(audio).strip().lower()
            print("Google Speech Recognition thinks you said " + text)
            try:
                question,result = self.visitor.parse(text)
                if question == 'love':
                    print("Hans loves you too!")
                    self.neigh()
                else:
                    result = int(result)
                    print("It's {}".format(result))
                    if result<=0 or result>100:
                        self.neigh()
                    else:
                        self.clop(result)
            
            except ParseError:
                print("Neigh!")
                self.neigh()

        except sr.UnknownValueError:
            print("Google Speech Recognition could not understand audio")
            return
        except sr.RequestError as e:
            print("Could not request results from Google Speech Recognition service; {0}".format(e))

hans = Horse(listen_continuously=False)

while True: 
    sleep(0.1)
                    

christianp.github.io/clever-hans