A while back, I wrote Jeffbot as a fun side project because I was bored between classes in college. JeffBot is simply a chatbot with the intelligence of a two-year-old. It learns words and what words can/can’t be used sequentially in a sentence. At first, it spews out pure garbage or just repeats back what you just said. However, over time, JeffBot learns how to make what appears to be intelligent english.

JeffBot itself is a complex app split over three different Heroku dynos and a particularly large database running on top of Rails/Sinatra/ASP.NET/I’m not even sure what anymore. In this article, I’m going to show you how to rebuild an N2 version of JeffBot in pure Ruby that can run locally with no external service/library dependencies.

First, you will need to set up a directory for JeffBot. Create a directory called JeffBot and cd into it. From now on, this directory will be referred to as ~/. Inside this directory, create a file called main.rb and paste this code in.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# ~/main.rb

def learn(input, training_data)
    grams = input.split(' ').each_cons(2).to_a
    grams.each do |gram|
        entries = training_data.select { |entry| entry.w1.eql?(gram[0]) && entry.w2.eql?(gram[1]) }
        if entries.count == 0 || entries.nil?
            training_data << Struct::Ngram.new(gram[0], gram[1], 1)
        else
            entries.first.count += 1
        end
    end
end

def respond(input, training_data)
    word = input.split(" ").sample
    sentence = word
    for i in 0..10
        entries = training_data.select { |entry| entry.w1.eql?(word) }
        if entries.count == 0 || entries.nil?
            sentence << "."
            break
        else
            new_word = entries.sample.w2
            sentence << " " + new_word
            word = new_word
        end
    end
    return sentence
end

Struct.new("Ngram", :w1, :w2, :count)

learning_data = []

while true
    print ">"
    input = gets.chomp
    if input.eql?("exit")
        exit(0)
    end
    learn(input, learning_data)
    puts respond(input, learning_data)
end

Let’s go through this code block by block to see what it does. Let’s start in the same place that the interpreter starts, line 32. This defines a struct called an N-gram. This represents a relationship between how likely one word is to appear after another. Take a look at the following input string:

This is a sentence.

Our learn() function that we are going to cover in a minute is going to split that sentence into 2n N-grams like so.

[This, is]
[is, a]
[a, sentence]

Assuming this is the only training data provided, if we start with “a”, our chatbot would choose “sentence” as the next word. If multiple relations exist for one word, a random selection is made based on the choices (utilizing the count attribute is left as an exercise for the reader).

The remainder of the code creates a learning_data[] array that we use to store training data. It also provides a loop to take input from the user, and exit the application if they have requested to do so by typing “exit” at the prompt. Towards the bottom of the code block, we see calls to learn() and respond(). These two methods form the backbone logic of our chatbot.

Now let’s take a look at the learn() method. We call it with two parameters, input and training_data[]. The first thing we need to do is convert input into a series of 2N-grams like so.

1
grams = input.split(' ').each_cons(2).to_a

Next we iterate over the list of grams, with gram representing the relation we are currently looking at.

1
entries = training_data.select { |entry| entry.w1.eql?(gram[0]) && entry.w2.eql?(gram[1]) }

This line pulls out an N-gram relation if it already exists in our training data where both the first word and the second word match in the gram we are currently looking at.

1
2
3
4
5
if entries.count == 0 || entries.nil?
    training_data << Struct::Ngram.new(gram[0], gram[1], 1)
else
    entries.first.count += 1
end

This if block runs some logic on the results from the previous query of training_data[]. If a relation already exists, we increment the count. Otherwise we create a new Ngram Struct and shove it into training_data[].

Now let’s move on to the respond() method. We call this with the same parameters as learn(). Our first line picks the first word based on the words that the user wrote at the prompt. We use this word to start our sentence and to initialize word that we will utilize in a loop.

1
2
    word = input.split(" ").sample
    sentence = word

Next we enter a for loop. I set an arbitrary limit of 12 words in a sentence, you can increment this by increasing the range that the for loop iterates over.

1
entries = training_data.select { |entry| entry.w1.eql?(word) }

Within this for loop, we use similar logic from the learn() method. The difference is that we only have the first part of the N-gram (w1). If the number of matchine entries is zero, we add a period to the sentence and return it to the prompt loop.

1
2
3
if entries.count == 0 || entries.nil?
    sentence << "."
    break

If matches are found, we assign it to new_word. Then we add it to sentence and move it to word so we can use it as our new w1 on the next loop iteration.

That’s all there is to it. Run it by cd-ing into your project directory and running ruby ./main.rb from the terminal. When you recieve the > prompt, enter a string of words. Your new chatbot should reply back with some garbage. Talk with it some more, the more input you provide, the faster it can learn and respond with some more intelligent-sounding english.

To quit, just type “exit” at the > prompt. Note that learning data is stored in memory. This means that when you quit, all training data is lost. When you run the program again, you are starting with a fresh slate. For more permanent storage, take a look at the pg ruby gem paired with the PostgreSQL database service.