Conversations With Computers

By

Conversations with Computers or: How I Learned to Stop Worrying and Love the Generator

One of the features I’d put off developing for Automaton is being able to hold “conversations” with a service. Because services were defined as functions they took in your input and returned output back to you. If you didn’t provide enough information, the service terminated with an UnsuccessfulExecution exception and you had to try again. From the beginning. If the input given was Give me directions to Canada and the service could not determine your current location, it would respond with something like Cannot determine your current location. Please try again. To actually get directions, you would have to duplicate your request with the missing data added: Give me directions from North Dakota to Canada.

It would be 1000x better if instead the service said “I could not determine your location, where do you want to start from?”, letting me reply with “North Dakota” to get my directions. Unfortunately, this is a networked application - the client I send my requests to talks to the server holding this conversation with me. How do I manage a conversation when the connection can drop at any moment? Further, the “entry point” that my client talks to is the exact same that another simultaneous client is using - one of the pitfalls of the non-threadsafe Thrift server I’m developing with.

For a moment, I considered using classes for the services instead of functions so I could take advantage of state, but the Plugin that hosts services is already a class and I didn’t want tons of nested classes. Plus, classes that conform to whatever conversation interface I’d cook up would add a lot of boilerplate for plugin developers, especially if they didn’t want to take advantage of this new feature. Luckily, I stumbled upon this beautiful piece of work in the Python PEPs. If you’re not familiar, PEPs are very well-written proposals for new features to Python. Most, if not all of the new features Python gains are part of one of these specifications. If you’re interested in finding new cool ways to use Python, I highly recommend you browse the list for interesting titles. This PEP, in particular, proposes a syntax for turning Python functions (subroutines) into coroutines, subroutines with multiple entry points (as opposed to regular functions always beginning execution at the “top” of the function). This isn’t supposed to be an introduction to generators, so if you’re unfamiliar with them I will instead point you toward two great tutorials on them if you need to brush up - go ahead, this page won’t expire by the time you get back.

The key to conversations here is the ability to send values into generators through generator.send(value). Here’s an example:

def generator():
  response = yield
  for count in range(5):
    response = yield "I got " + str(response)
  
gen = generator()
gen.next()  # This starts the generator, now we can send values in.
count = 0
try:
  while True:  # Keep sending things until the generator gets tired
    print gen.send("something new " + str(count))
    count = count + 1
except StopIteration:
pass # The Generator is finished.

Now that we can give our services extra input while they’re in the middle of execution, let’s have an actual conversation with our computer:

def ask_about_weather():
  response = (yield "Hey! How is the weather today?")

  if "cold" in response:
    yield "You should get a jacket."
  elif "warm" in response:
    response = yield "Isn't this weather awesome?"
    yield "Lets go outside."
  elif "hot" in response:
    yield "You should go to the beach!"
  else:
    yield "Keep on topic!"

if __name__ == "__main__":
  try:
    gen = ask_about_weather()
    print gen.next()
    while True:
      inp = raw_input("> ")
      print gen.send(inp)
  except StopIteration:
    pass

Here we have a reversal of the intended conversation roles. When this script is run, the computer asks “How is the weather?”. When you type something into raw_input, it gets sent into the generator and the computer has different responses depending on whether or not you typed the words “cold”, “hot”, or “warm”. If you type “warm”, the computer asks a second question and you’re given a second chance to respond. Run through a conversation and you’ll notice that after the conversation comes to an end, control will wrap around to the raw_input function one more time. You’re forced to hit enter and then… the program ends. If the last yield statement in each branch was assigned to anything, that last raw_input value would be assigned, but control passes to the end of the function (and an implied return) and a StopIteration exception is raised. All generators and iterators will raise that exception when they finish but for loops silently catch them and exit the loop so you may never have run into them before. Try it:

>>> i = iter(range(0))
>>> i.next()
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
StopIteration

I managed to find two solutions to this problem (though there are probably more - Pythonistas are a creative bunch). The first is kind of a hack. Generators are not allowed to pass anything to return because immediately after the StopIteration exception is thrown. Following that line of thought, I replaced all returns with manual throws of StopIteration, passing the final message as an argument.

def ask_about_weather():
  response = (yield "Hey! How is the weather today?")
  response = yield "Isn't this weather awesome?"
  raise StopIteration("Lets go outside.")

if __name__ == "__main__":
  try:
    gen = ask_about_weather()
    print gen.next()
    while True:
      inp = raw_input("> ")
      print gen.send(inp)
  except StopIteration as err:
    print err

I’m not too keen on abusing raise to return a value instead of the normal process of automatically raising StopIteration when control leaves the function, but it does work. My second solution takes advantage of the fact that the next input to the computer is going to be a new conversation. That “final” raw_input then becomes the new first input to the next conversation. Below is an implementation similar to what I ended up using in my program:

def directions(arg):
  arg = arg.split()
  to = None
  frm = None
  if "to" in arg:  # Populate "to" and "frm" if given
    to = arg[arg.find("to") + 1]
  if "from" in arg:
    frm = arg[arg.find("from") + 1]
  if to is None:  # Ask for more info if necessary
    to = yield "Where do you want to go to?"
  if frm is None:
    frm = yield "Where are you coming from?"
  # Present final output
  yield ("Why do you want to go to " + str(to) +
        " when you are already in " + str(frm) + "?")

def weather(arg):
  arg = arg.split()
  location = None
  if "in" in arg:
    location = arg[arg.find("in") + 1]
  if location is None:
    location = yield "Where do you want weather for?"
  yield "The weather for %s is terrible." % str(location)

if __name__ == "__main__":
  current_conversation = None
  while True:
    inp = raw_input("> ")
    if current_conversation is not None:
      try:
        print current_conversation.send(inp)
      except StopIteration:
        current_conversation = None
    # Note no elif - when StopIteration happens, we
    # fall through and start a new conversation with inp
    if current_conversation is None:
      if "directions" in inp:  # Choose which service to use
        current_conversation = directions(inp)
      elif "weather" in inp:
        current_conversation = weather(inp)
      else:
        break
      print current_conversation.next()  # Go to first "yield" statement

Both directions and weather are pretty much the same thing for differing numbers of “arguments”. If there is missing information, one of the conditional yield statements is executed, otherwise the final output is printed. After the final output is printed, the next input is used to start an entirely new conversation.

> I want the weather.
Where do you want weather for?
> Kansas
The weather for Kansas is terrible.
> Give me directions to Canada.
Where are you coming from?
> America
Why do you want to go to Canada when you are already in America?

Of course, you’ll notice that you can only hold one conversation at a time. That’s fine for this application, but what about in a networked environment with multiple connected users? Make each user either register or create their own ID, then send that along with every message, then change current_conversation to a dictionary with the ID as the key. And that’s it, you can now talk to computers! Watch out for weird stares.