Building a Chat Bot in Neo4j Part 2

In part one our this building a chatbot series, we figured out how to use OpenNLP to “hear” what a user is saying and figure out both their intent and any entities they may have mentioned. Today we’re going to learn how to use Neo4j to talk back… like an impudent child.

We haven’t done any graph modeling yet, so let’s tackle part of this. Our chatbot will be used by a team of Shadowrunners under a single account, but by different members of the team. We need Account nodes and these nodes will have Members that send us Messages, and we’ll have to Reply to those messages. The messages will be in order, so we can chain them together in a list. It looks like this:

Let’s build another stored procedure to chat with our members. It will take the id of the account and the phone number of the member chatting with us and what they are saying.

 
    @Procedure(name = "com.maxdemarzi.chat", mode = Mode.WRITE)
    @Description("CALL com.maxdemarzi.chat(String id, String phone, String text)")
    public Stream<IntentResult> chat(@Name(value = "id") String id,
                                     @Name(value = "phone") String phone,
                                     @Name(value = "text") String text) {
        ArrayList<IntentResult> results = new ArrayList<>();

The first thing we’ll do is find the account and member, so we know who is chatting with us.

 
        Node account = db.findNode(Labels.Account, ID, id);
        Node member = null;
        for (Relationship hasMember : account.getRelationships(Direction.OUTGOING, 
                                                               RelationshipTypes.HAS_MEMBER)) {
            member = hasMember.getEndNode();
            if (member.getProperty(PHONE).equals(phone)) {
                break;
            } else {
                member = null;
            }
        }

Next we need to create the message node to store what they chatted and when:

 
        Node next = db.createNode(Labels.Message);
        next.setProperty(TEXT, text);
        next.setProperty(DATE, ZonedDateTime.now());

…and we need to connect this message in to the Message chain. We do this by checking to see if the member has any previous messages and if so we get the last message, delete the relationship from the member to this last message and create a new relationship from this next message to the last message.

 
        if (member.hasRelationship(Direction.OUTGOING, PREV_MESSAGE)) {
            Relationship prev = member.getSingleRelationship(PREV_MESSAGE, Direction.OUTGOING);
            Node last = prev.getEndNode();
            prev.delete();
            next.createRelationshipTo(last, PREV_MESSAGE);
        }

That last bit kinda leaves us dangling, not to worry we’ll take care of connecting the chain up next by adding a relationship between this next message to the Member node.

 
        member.createRelationshipTo(next, PREV_MESSAGE);

Ok, now what? Well, we need to find the intents of the message and respond.

 
        ArrayList<IntentResult> results = new ArrayList<>();
        findIntents(text, results);

        for (IntentResult result : results) {
            respond(member, result, next);
        }
        return results.stream();

Sounds simple, but we haven’t figured out how the “respond” method is going to come up with a response. For this first stab at building a chatbot, I figured each Intent will have a set of decisions leading to a reply based on the information we have. Some of those replies may be requests for more information, others will simply display the information requested, and others still will be actions we need to take. Like making an order, cancelling an order, making an existing order “Rushed”, etc.

If you are a long time reader, you may be guessing where I’m going with this. If not, I encourage you to watch this video on building Decision Trees in Neo4j and read the 1, 2, 3, 4 part series on the subject. What will this look like:

Let’s start with something simple like the “greeting” intent. If we know the name of the Member, we want to greet them with their name. If not, we will great them with a generic greeting.

This decision tree will be named “greeting” and have a single rule node. The rule is an expression that asks if the “name” property parameter passed in is empty or not.

 
"CREATE (tree:Tree { id: 'greeting' })" +
"CREATE (name_blank_rule:Rule { parameter_names: 'name', 
         parameter_types:'String', expression:'name.isEmpty()' })" +
"CREATE (answer_yes:Answer { id: 'greeting-yes' })" +
"CREATE (answer_no:Answer { id: 'greeting-no' })" +
"CREATE (tree)-[:HAS]->(name_blank_rule)" +

If the expression evaluates to true, we go to the “yes” Answer node, otherwise the “no” Answer node.

 
"CREATE (name_blank_rule)-[:IS_TRUE]->(answer_yes)" +
"CREATE (name_blank_rule)-[:IS_FALSE]->(answer_no)" +

Off each Answer node, we will hang “Responses”. We need at least one Response per Answer node, but we can have as many as we want. When we go to respond we will choose one, but by having a few the chatbot won’t feel so robotic. We could even add a personality property to these Responses so members can feel like they are “chatting” with different people, or add a language property to make our chatbot multilingual:

 
"CREATE (i1r1:Response {text:'Hi $name!', parameter_names:['name']})" +
"CREATE (i1r2:Response {text:'Hello $name!', parameter_names:['name']})" +
"CREATE (i1r3:Response {text:'Hello there!', parameter_names:[]})" +
"CREATE (i1r4:Response {text:'Hiya!', parameter_names:[]})" +
"CREATE (answer_no)-[:HAS_RESPONSE]->(i1r1)" +
"CREATE (answer_no)-[:HAS_RESPONSE]->(i1r2)" +
"CREATE (answer_yes)-[:HAS_RESPONSE]->(i1r3)" +
"CREATE (answer_yes)-[:HAS_RESPONSE]->(i1r4)" +

Let’s start on the respond method. First thing we need to do is find the root of the decision tree named after the intent we are replying to.

 
private void respond(Node account, IntentResult result, Node next) {
   // Which Decision Tree are we interested in?
   Node tree = db.findNode(Labels.Tree, ID, result.intent);
   if ( tree != null) {

You may have noticed above some of the responses have parameters that need to be filled in. How are we going to do that? Well, we need to gather some facts about the account. For now we’ll gather facts about the member and the time of day. We’ll look at these later.

 
FactGenerator factGenerator = new FactGenerator(db, account);
Map<String, Object> facts = new HashMap<>();
factGenerator.getMemberFacts(facts);
factGenerator.getTimeFacts(facts);

Once we have a bunch of facts, we’ll run the decisionPath method to get an Answer node. From this answer node, we’ll get the potential responses.

 
Stream<Path> paths = decisionPath(tree, facts);
Path path = paths.findFirst().get();
ArrayList<String> potentialResponses = new ArrayList<>();
for (Relationship responses : path.endNode().getRelationships(Direction.OUTGOING, 
                                                RelationshipTypes.HAS_RESPONSE)) {
    Node response = responses.getEndNode();
    potentialResponses.add((String)response.getProperty(TEXT, ""));
}

We’ll pick one of those potential responses at random for now, but we could get really fancy here with personalities and languages as mentioned earlier.

 
Random rand = new Random();
String response = potentialResponses.get(rand.nextInt(potentialResponses.size()));
response = fillResponseWithFacts(facts, response);
result.setResponse(response);

We need to fill those responses with some of the facts we gathered earlier. But before we dive into facts, let’s save our reply in the graph to end this method:

 
Node reply = db.createNode(Labels.Reply);
reply.setProperty(INTENT, result.intent);
reply.setProperty(TEXT, result.response);
String[] args = convertArgsToStringArray(result);
reply.setProperty(ARGS, args);

next.createRelationshipTo(reply, RelationshipTypes.HAS_REPLY);

About them facts. We want to provide the most information possible to our decision tree so we take the right branches and get a good response. So we can start by gathering what we know of the member. For example if we knew the “name” or “birthdate” property of the Member, we could greet them with their name or tell them Happy Birthday!

 
    public void getMemberFacts( Map<String, Object> facts) {
        facts.put("member_node_id", member.getId());
        Result factResult = db.execute("MATCH (member:Member) 
               WHERE ID(member) = $member_node_id RETURN PROPERTIES(member) AS properties", facts);
        Map<String, Object> factMap = (Map<String, Object>)factResult.next().get("properties");
        facts.putAll(factMap);
        facts.putIfAbsent("name", "");
    }

We can also vary our response based on the time of day. So we can gather that and add it to our facts.

 
    public void getTimeFacts(Map<String, Object> facts) {
        facts.put("time_of_day", new TimeOfDay(LocalTime.now()).getTimeOfDay());
    }

If you recall the “greeting” decision tree, we checked the name property before deciding how to respond. If we have it we can respond with it, but in order to do that we need to fill it in. So we take our response text and replace the parameters with any facts we may have.

 
private String fillResponseWithFacts(Map<String, Object> facts, String response) {
    // Fill in facts
    for (Map.Entry<String, Object> entry : facts.entrySet()) {
       String key = "\\$" + entry.getKey();
       response = response.replaceAll(key, entry.getValue().toString() );
    }
    return response;
}

Let’s test this out. Given an existing graph with two accounts and two members, one with a name and one without:

 
    private static final String MODEL_STATEMENT =
            "CREATE (a1:Account {id:'a1'})" +
            "CREATE (m1:Member {name:'Max De Marzi', phone:'123'})" +
            "CREATE (a1)-[:HAS_MEMBER]->(m1)" +
            "CREATE (a2:Account {id:'a2'})" +
            "CREATE (m2:Member { phone:'456'})" +
            "CREATE (a2)-[:HAS_MEMBER]->(m2)";

When the first member chats “Hello?”

 
result = session.run( "CALL com.maxdemarzi.chat($id, $phone, $text)",
     parameters( "id", "a1", "phone", "123", "text", "Hello?" ) );

We get a reply that is a greeting and ends with their name.

 
Record record = result.single();
assertThat(record.get("intent").asString()).isEqualTo("greeting");
assertThat(record.get("response").asString()).endsWith("Max De Marzi!");

When the second member chats “Hello?”:

 
result = session.run( "CALL com.maxdemarzi.chat($id, $phone, $text)",
parameters( "id", "a2", "phone", "456", "text", "Hello?" ) );

We do not reply with their name, because we don’t have it.

 
record = result.single();
assertThat(record.get("intent").asString()).isEqualTo("greeting");
assertThat(record.get("response").asString()).doesNotContain("Max");

Alright, if you made it this far, we now have a chatbot that can say hello and goodbye. Which is great, but not super useful. Our Shadowrunners want to get information on gear, make purchases, rush orders, and more than just have a short chat. We’ll dive into some of that in Part 3, if you want to get ahead of me, take a look at the source code.

Tagged , , , , , , , , ,

Leave a comment