def clear_rules(self): """ Empty the rules database """ log.debug("Rules database cleared") self.rules_db = RulesDB()
class ChatbotEngine(object): """ Python Chatbot Reply Generator Loads pattern/reply rules, using a simplified regular expression grammar for the patterns, and decorated Python methods for the rules. Maintains rules, a variable dictionary for the chatbot's internal state plus one for each user conversing with the chatbot. Matches user input to its database of patterns to select a reply rule, which may recursively reference other reply patterns and rules. Public instance methods: load_script_directory: loads rules from a directory of python files clear_rules: empties the rule database reply: given a message, find the best matching rule, run it, and return the reply """ def __init__(self, depth=50): """Initialize a new ChatbotEngine. Keyword argument: depth -- Recursion depth limit for replies that reference other replies """ self._depth_limit = depth self._botvars = {} self._variables = {"b": self._botvars, "u": None} self._users = {} # will contain UserInfo objects log.debug("Chatbot instance created.") self.clear_rules() def clear_rules(self): """ Empty the rules database """ log.debug("Rules database cleared") self.rules_db = RulesDB() def load_script_directory(self, directory, ignore_errors=False): """ Load rules from *.py in a directory """ self.rules_db.load_script_directory(directory, self._botvars, ignore_errors) def reply(self, user, user_dict, message): """ For the current topic, find the best matching rule for the message. Recurse as necessary if the first rule returns references to other rules. This method does setup and cleanup and passes the actual work to self._reply() Arguments: user -- any hashable value, used to identify to whom we are speaking user_dict -- dictionary of information about the user, to be passed to rule methods message -- string (not bytestring!) to reply to Return value: string returned by rule(s) Exceptions: RecursionTooDeepError -- if recursion goes over depth limit passed to __init__ """ if not isinstance(message, text_type): raise TypeError("message argument must be string, not bytestring") self.rules_db.sort_rules() log.debug('Asked to reply to: "{0}" from {1}'.format(message, user)) self._setup_user(user, user_dict) try: reply = self._reply(user, message, 0) except RecursionTooDeepError as e: e.args = ('Could not find reply to "{0}", due to rules ' "referencing other rules too many " "times".format(message), ) raise self._remember(user, message, reply) return reply def _reply(self, user, message, depth): """ Recursively construct replies """ if depth > self._depth_limit: raise RecursionTooDeepError log.debug('Searching for rule matching "{0}", depth == {1}'.format( message, depth)) userinfo = self._users[user] topic = userinfo.topic_name target = Target(message, self.rules_db.topics[topic].substitutions) reply = "" for rule in self.rules_db.topics[topic].sortedrules: m = rule.match(target, userinfo.repl_history, self._variables) if m is not None: reply = self._reply_from_rule(rule, m, userinfo) self._check_for_topic_change(user, rule, topic, userinfo.topic_name) break reply = self._recursively_expand_reply(user, reply, depth) if not reply: log.debug("Empty reply generated") else: log.debug("Generated reply: " + reply) return reply def _reply_from_rule(self, rule, rule_match, userinfo): """ Given a rule and the results from a successful match of the rule's pattern, call the rule method and return the results. """ log.debug("Found match, rule {0}".format(rule.rulename)) inst = get_method_self(rule.method) inst.userinfo = userinfo inst.match = rule_match.dict reply = rule.method() if not isinstance(reply, text_type): raise TypeError("Rule {0} returned something other than a " "string.".format(rule.rulename)) log.debug('Rule {0} returned "{1}"'.format(rule.rulename, reply)) return reply def _recursively_expand_reply(self, user, reply, depth): """ Given a reply string from a rule, look for references to other rules enclosed within < > and recursively call _reply to get responses, and substitute those into the original string. Evaluates from left to right. Doesn't care if you match the <>'s or not. """ matches = [m for m in re.finditer("<(.*?)>", reply, flags=re.UNICODE)] if matches: log.debug("Rule returned: " + reply) sub_replies = [ self._reply(user, m.groups()[0], depth + 1) for m in matches ] zipper = list(zip(matches, sub_replies)) zipper.reverse() for m, sub_reply in zipper: reply = reply[:m.start()] + sub_reply + reply[m.end():] return reply def _check_for_topic_change(self, user, rule, old_topic, new_topic): """ Given a rule, and the topic set before and after its execution, make sure the change is legit and do appropriate debug logging. """ if old_topic != new_topic: if new_topic not in self.rules_db.topics: log.warning("Rule {0} changed to empty topic {1}, " "returning to 'all'".format( rule.rulename, new_topic)) new_topic = "all" log.debug("User {0} now in topic {1}".format(user, new_topic)) self._users[user].topic_name = new_topic def _setup_user(self, user, user_dict): """ Set up the Script class to process a message from a user. If the user is new to us, create the UserInfo object for them, and call the setup_user method of all the script instances so they can initialize user variables. """ new = (user not in self._users) if new: self._users[user] = UserInfo(user_dict) else: self._users[user].info.update(user_dict) self._variables["u"] = self._users[user].vars topic = self._users[user].topic_name if topic not in self.rules_db.topics: log.warning("User {0} is in empty topic {1}, " "returning to 'all'".format(user, topic)) topic = self._users[user].topic_name = "all" if new: log.debug("New user, running all scripts' setup_user methods") for inst in self.rules_db.script_instances: inst.userinfo = self._users[user] inst.setup_user(user) def _remember(self, user, message, reply): """ Save recent messages and replies, per user """ user_info = self._users[user] topic_name = user_info.topic_name user_info.msg_history.appendleft(message) user_info.repl_history.appendleft( Target(reply, self.rules_db.topics[topic_name].substitutions))
class ChatbotEngine(object): """ Python Chatbot Reply Generator Loads pattern/reply rules, using a simplified regular expression grammar for the patterns, and decorated Python methods for the rules. Maintains rules, a variable dictionary for the chatbot's internal state plus one for each user conversing with the chatbot. Matches user input to its database of patterns to select a reply rule, which may recursively reference other reply patterns and rules. Public instance methods: load_script_directory: loads rules from a directory of python files clear_rules: empties the rule database reply: given a message, find the best matching rule, run it, and return the reply """ def __init__(self, depth=50): """Initialize a new ChatbotEngine. Keyword argument: depth -- Recursion depth limit for replies that reference other replies """ self._depth_limit = depth self._botvars = {} self._variables = {"b": self._botvars, "u": None} self._users = {} # will contain UserInfo objects log.debug("Chatbot instance created.") self.clear_rules() def clear_rules(self): """ Empty the rules database """ log.debug("Rules database cleared") self.rules_db = RulesDB() def load_script_directory(self, directory, ignore_errors=False): """ Load rules from *.py in a directory """ self.rules_db.load_script_directory(directory, self._botvars, ignore_errors) def reply(self, user, user_dict, message): """ For the current topic, find the best matching rule for the message. Recurse as necessary if the first rule returns references to other rules. This method does setup and cleanup and passes the actual work to self._reply() Arguments: user -- any hashable value, used to identify to whom we are speaking user_dict -- dictionary of information about the user, to be passed to rule methods message -- string (not bytestring!) to reply to Return value: string returned by rule(s) Exceptions: RecursionTooDeepError -- if recursion goes over depth limit passed to __init__ """ if not isinstance(message, text_type): raise TypeError("message argument must be string, not bytestring") self.rules_db.sort_rules() log.debug('Asked to reply to: "{0}" from {1}'.format(message, user)) self._setup_user(user, user_dict) try: reply = self._reply(user, message, 0) except RecursionTooDeepError as e: e.args = ('Could not find reply to "{0}", due to rules ' "referencing other rules too many " "times".format(message),) raise self._remember(user, message, reply) return reply def _reply(self, user, message, depth): """ Recursively construct replies """ if depth > self._depth_limit: raise RecursionTooDeepError log.debug('Searching for rule matching "{0}", depth == {1}'.format( message, depth)) userinfo = self._users[user] topic = userinfo.topic_name target = Target(message, self.rules_db.topics[topic].substitutions) reply = "" for rule in self.rules_db.topics[topic].sortedrules: m = rule.match(target, userinfo.repl_history, self._variables) if m is not None: reply = self._reply_from_rule(rule, m, userinfo) self._check_for_topic_change(user, rule, topic, userinfo.topic_name) break reply = self._recursively_expand_reply(user, reply, depth) if not reply: log.debug("Empty reply generated") else: log.debug("Generated reply: " + reply) return reply def _reply_from_rule(self, rule, rule_match, userinfo): """ Given a rule and the results from a successful match of the rule's pattern, call the rule method and return the results. """ log.debug("Found match, rule {0}".format(rule.rulename)) inst = get_method_self(rule.method) inst.userinfo = userinfo inst.match = rule_match.dict reply = rule.method() if not isinstance(reply, text_type): raise TypeError("Rule {0} returned something other than a " "string.".format(rule.rulename)) log.debug('Rule {0} returned "{1}"'.format(rule.rulename, reply)) return reply def _recursively_expand_reply(self, user, reply, depth): """ Given a reply string from a rule, look for references to other rules enclosed within < > and recursively call _reply to get responses, and substitute those into the original string. Evaluates from left to right. Doesn't care if you match the <>'s or not. """ matches = [m for m in re.finditer("<(.*?)>", reply, flags=re.UNICODE)] if matches: log.debug("Rule returned: " + reply) sub_replies = [self._reply(user, m.groups()[0], depth + 1) for m in matches] zipper = list(zip(matches, sub_replies)) zipper.reverse() for m, sub_reply in zipper: reply = reply[:m.start()] + sub_reply + reply[m.end():] return reply def _check_for_topic_change(self, user, rule, old_topic, new_topic): """ Given a rule, and the topic set before and after its execution, make sure the change is legit and do appropriate debug logging. """ if old_topic != new_topic: if new_topic not in self.rules_db.topics: log.warning("Rule {0} changed to empty topic {1}, " "returning to 'all'".format( rule.rulename, new_topic)) new_topic = "all" log.debug("User {0} now in topic {1}".format(user, new_topic)) self._users[user].topic_name = new_topic def _setup_user(self, user, user_dict): """ Set up the Script class to process a message from a user. If the user is new to us, create the UserInfo object for them, and call the setup_user method of all the script instances so they can initialize user variables. """ new = (user not in self._users) if new: self._users[user] = UserInfo(user_dict) else: self._users[user].info.update(user_dict) self._variables["u"] = self._users[user].vars topic = self._users[user].topic_name if topic not in self.rules_db.topics: log.warning("User {0} is in empty topic {1}, " "returning to 'all'".format(user, topic)) topic = self._users[user].topic_name = "all" if new: log.debug("New user, running all scripts' setup_user methods") for inst in self.rules_db.script_instances: inst.userinfo = self._users[user] inst.setup_user(user) def _remember(self, user, message, reply): """ Save recent messages and replies, per user """ user_info = self._users[user] topic_name = user_info.topic_name user_info.msg_history.appendleft(message) user_info.repl_history.appendleft( Target(reply, self.rules_db.topics[topic_name].substitutions))