Building a Chat Bot in Neo4j Part 3

In part one, we learned to listen to our users, in part two we began learning how to talk back. Before we go any further in to the stored procedure, how about we build a little front end to show off the work we’ve done so far on this proof of concept? That will also make things easier to test out and let us get into the mindset of the user. There are a ton of options here, lots of folks like Spring and Spring Boot. Others are more hipsters and into Micronaut. I am even more of a hipster and prefer to use Jooby, but it doesn’t really matter. We’ll be using Cypher, the Neo4j Drivers and the Stored Procedures we build along the way so technically you can do this in just about any language.

I’m not a designer, so searched around online for a simple chat platform theme and literally found “Swipe: The Simplest Chat Platform” for $19 bucks. That will save me a ton of time messing with css which I consider the dark magic of web development.

Jooby projects start out pretty simple, follow along with the documentation and you will end up with something like this:

 
import io.jooby.Jooby;

public class App extends Jooby {

  {
    get("/", ctx -> "Welcome to Jooby!");
  }

  public static void main(String[] args) {
    runApp(args, App::new);
  }
}

We don’t really want to welcome people to Jooby, we want to let them try out our chatbot application so let’s change that text to instead ask the user to sign in or register instead:

How did we get that? We built an index page and routed it to the “/” path. We’ll do the same for registration:

 
get("/", ctx -> views.index.template());
get("/register", ctx -> views.register.template());

I’m not going to show the html required for those pages, you can click on the links to see them if you wish, but I will show you the result:

Before we get much further, we need to be able to talk to Neo4j. We need to tell it that a user is trying to register for that we will add the Neo4j Java driver to the pom.xml file and build a little Jooby extension. The Neo4jExtension will connect to Neo4j using a configured uri, username and password, then register the driver on to the application. It looks like this:

 
public class Neo4jExtension implements Extension {

    @Override
    public void install(@Nonnull Jooby application) throws Exception {
        Environment env = application.getEnvironment();
        Config conf = env.getConfig();

        Driver driver = GraphDatabase.driver(conf.getString("neo4j.uri"),
                AuthTokens.basic(conf.getString("neo4j.username"), 
                                 conf.getString("neo4j.password")));

        ServiceRegistry registry = application.getServices();
        registry.put(Driver.class, driver);
        application.onStop(driver);
    }

}

Now we need to write the post register endpoint. Our form is passing in an id, a password and a phone number for our user. We will encrypt the password, require the Neo4j Driver and use it to send a Cypher Query to Neo4j to create the user.

 
        post("/register", ctx -> {
            Formdata form = ctx.form();
            String id = form.get("id").toOptional().orElse("");
            String password = form.get("password").toOptional().orElse("");
            String phone = form.get("phone").toOptional().orElse("");
            password = BCrypt.hashpw(password, BCrypt.gensalt());
            Driver driver = require(Driver.class);
            Map<String, Object> user = CypherQueries.CreateMember(driver, id, phone, password);

Some people like having Cypher queries all over their codebase. I kinda like having them mostly huddled together. So we’ll create a CypherQueries interface where will stick them for now. We’ll need a few helper methods as well. One to create a session from our driver and execute the query given a set of parameters, and return an iterator of maps to make things easier to work with:

 
    static Iterator<Map<String, Object>> query(Driver driver, String query, 
                                               Map<String, Object> params) {
        try (Session session = driver.session()) {
            List<Map<String, Object>> list = session.run(query, params)
                    .list( r -> r.asMap(CypherQueries::convert));
            return list.iterator();
        }
    }

The convert method to bring back everything back in a way that is easily convertible to a map:

 
    static Object convert(Value value) {
        switch (value.type().name()) {
            case "PATH":
                return value.asList(CypherQueries::convert);
            case "NODE":
            case "RELATIONSHIP":
                return value.asMap();
        }
        return value.asObject();
    }

With that plumbing out of the way we can get down to what’s really needed. A cypher query to create the account and member:

 
String createMember = "CREATE (a:Account { id: $id, password: $password })
                              -[:HAS_MEMBER]->(member:Member { phone: $phone }) 
RETURN a.id AS id, member.phone AS phone";

…and a method to tie things together.

 
    static Map<String, Object> CreateMember(Driver driver, String id, 
                                            String phone, String password) {
        Map<String, Object> response = Iterators.singleOrNull(query(driver, createMember,
                new HashMap<String, Object>() {{
                    put("id", id);
                    put("phone", phone);
                    put("password", password); }}
            ));

If you take a close look at that picture, you’ll see that the member node has a whole bunch of properties we didn’t ask for. Like Name, Location, Gender, etc. What gives? Well I don’t want to waste the user’s time asking them things I can figure out on my own. So I wrote another extension to call the FullContact API with the email and phone number used in the registration to enrich that members information. One of the nice things about working with Neo4j is that it’s schema optional. So whatever properties I can get from FullContact I can add to the member via this cypher query:

 
String enrichUser = "MATCH (a:Account)-[:HAS_MEMBER]->(member) 
                     WHERE a.id = $id AND member.phone = $phone 
                     SET member += $properties 
                     RETURN member";

But there are some caveats to that. First, Neo4j doesn’t allow null values, so we have to get rid of those. Second, Neo4j doesn’t allow nested properties and both the “details” and “dataAddOns” come in as json blobs, so they have to go.

 
properties.values().removeIf(Objects::isNull);
properties.remove("details");
properties.remove("dataAddOns");

Third, the FullContact API has a rate limiter (specially for the free plan) so instead of doing the request at registration, it is sent to a queue that runs a background job once a second so we don’t exceed the limit.

 
enrichmentJob.queue.add(new HashMap<String, Object>() {{
    put("email", id);
    put("phone", phone);
}});

Now we need to be able to use the stored procedure to chat with our user. We can call it just like any Cypher query from the driver, passing in the id and phone of the member as well as the text they sent us:

 
    String chat = "CALL com.maxdemarzi.chat($id, $phone, $text)";

    static List<Map<String, Object>> Chat(Driver driver, String id, String phone, String text) {
        return Iterators.asList(
            query(driver, chat, new HashMap<String, Object>() {{
                put("id", id);
                put("phone", phone);
                put("text", text);
            }})
        );
    }

We’ll add an endpoint to accept their chat post request to our Jooby application that uses the method above and returns the response to our app.

 
        post("/chat", ctx -> {
            String id = ctx.session().get("id").value();
            String phone = ctx.session().get("phone").value();
            Formdata form = ctx.form();
            String chatText = form.get("chatText").toOptional().orElse("");
            Driver driver = require(Driver.class);
            List<Map<String, Object>> response = CypherQueries.Chat(driver, id, phone, chatText);
            return response;
        });

We will also wire it all together with some old school Javascript because that’s all I remember how to do and now for the fruits of our labor. When I type “hello” into the chat box and press enter, the stored procedure is called and it attaches a new Message to our Member node, figures out what to reply, adds that to our graph and returns the result.

Then we can visualize our reply in the chat window:

…and there we have it. Took some effort to get this far. In the next part, we’ll go beyond saying hello and get to some real functionality. The source code as always is on github.

Tagged , , , , , , , ,

One thought on “Building a Chat Bot in Neo4j Part 3

  1. I am just coming to Java from the .NET/C# world, so I have been learning Spring / Spring Boot. I was not aware of Micronaut or of Joovy, and I appreciate your pointing them out. Both of them look quite interesting to me. One of the things I am really digging about Java is Maven and being able to just reference things in the POM.xml file and have them (and their dependencies) automatically downloaded. This is a whole lot better than using NuGet in .NET. I also thought your use of the FullContact API for data enrichment was a nice touch. Thank you for sharing this!

Leave a comment