PythonMastery
intermediate 30 min read · lesson 15 of 15 in Projects

Project: Rule-Based Chatbot

1 · The lesson

read

You'll build a chatbot the way chatbots were built before neural networks: with regex patterns, response templates and a little state. Then we'll mark the exact place where you would call an LLM (a large language model), without making the call. You'll understand both parts: the rules and the place where an LLM fits.

What you'll practise: regex intent matching, response templates with captures, random.choice for personality, OOP for conversation state, the integration shape of an LLM fallback.


Step 1: The Simplest Version

A few lines, no imports, only if statements. It is very simple, but it works, and it is the starting point for every rule-based chatbot.

python
def respond(message):
    text = message.lower().strip()
    if text in ("hi", "hello", "hey"):
        return "Hello!"
    if "your name" in text:
        return "I'm a humble chatbot."
    if text in ("bye", "goodbye"):
        return "Goodbye!"
    return "I don't understand. Try 'hi' or 'bye'."

for msg in ["Hello", "What's your name?", "Tell me a joke", "bye"]:
    print(f"  you: {msg}")
    print(f"  bot: {respond(msg)}")

This works because there are very few possible inputs. Add five more intents, and the if chain becomes hard to read. It also can't handle "hi there!", because the exact match in ("hi",) doesn't match "hi there".

We need patterns.


Step 2: Pattern-Based Responses

Move the rules into data: a list of (regex, response) tuples. Loop over the list. The first rule that matches gives the reply. See regex if \b (word boundary) is new to you.

python
import re

RULES = [
    (r"\b(hi|hello|hey)\b",      "Hello there!"),
    (r"\bhow are you\b",         "I'm a string of code, so I'm always stable."),
    (r"\bwhat.*your name\b",     "I'm a humble chatbot."),
    (r"\b(bye|goodbye|see ya)\b","Goodbye!"),
    (r"\bthank(s| you)\b",       "You're welcome."),
]

def respond(message):
    text = message.lower()
    for pattern, reply in RULES:
        if re.search(pattern, text):
            return reply
    return "Sorry, I didn't catch that."

for msg in ["Hi there!", "How are you doing?", "what is your name?", "thanks", "bye"]:
    print(f"  you: {msg}")
    print(f"  bot: {respond(msg)}")

To add a new intent, you now add one tuple to RULES. The code stays the same. The patterns are data, and the code only applies them. This is the same idea as the Mad Libs templates earlier in this path.

re.search() (not re.match()) searches the whole string, so the pattern can match anywhere in the message. \b is a word boundary, so \bhi\b matches "hi" but not "this".


Step 3: Response Templates with Captures

The bot becomes more interesting when it can repeat your own words back to you. Use capture groups in the regex, and put the captured text into the response with format.

python
import re

RULES = [
    (r"\bmy name is (\w+)\b",         "Nice to meet you, {0}!"),
    (r"\bi (?:like|love) (\w+)\b",    "What is it about {0} that you like?"),
    (r"\bi(?:'m| am) (\w+)\b",        "Why are you {0}?"),
    (r"\b(hi|hello|hey)\b",           "Hello!"),
]

def respond(message):
    for pattern, template in RULES:
        m = re.search(pattern, message, re.IGNORECASE)
        if m:
            return template.format(*m.groups())
    return "Tell me more."

for msg in ["My name is Bob", "I love Python", "I'm tired", "hi"]:
    print(f"  you: {msg}")
    print(f"  bot: {respond(msg)}")

m.groups() returns a tuple of every captured group. template.format(*m.groups()) unpacks them so {0} is the first capture, {1} is the second, and so on. (?:...) is a non-capturing group: it matches "like" or "love", but the matched word is not one of the captured values.

ELIZA used this method in 1966. A pattern like I am (.+) with the reply Why are you {0}? gives answers that feel surprisingly human. Some people who talked to ELIZA felt that it really understood them, but the patterns did all the work.


Step 4: Multiple Possible Responses

A bot that says "Hello!" to every "hi" soon sounds like a machine. Give each rule a list of replies, and pick one at random.

python
import random
import re

RULES = [
    (r"\b(hi|hello|hey)\b", [
        "Hello!",
        "Hi there.",
        "Hey, what's up?",
    ]),
    (r"\bmy name is (\w+)\b", [
        "Nice to meet you, {0}.",
        "{0}, got it.",
        "Pleased to make your acquaintance, {0}.",
    ]),
    (r"\bhow are you\b", [
        "Doing well, thanks for asking.",
        "Same as yesterday: running on caffeine and Python.",
        "Excellent. You?",
    ]),
]

def respond(message):
    for pattern, replies in RULES:
        m = re.search(pattern, message, re.IGNORECASE)
        if m:
            template = random.choice(replies)
            return template.format(*m.groups())
    return random.choice(["Tell me more.", "Interesting.", "Go on."])

for msg in ["Hi!", "Hi!", "Hi!", "My name is Bob", "How are you?"]:
    print(f"  you: {msg}")
    print(f"  bot: {respond(msg)}")

Three simple things give the bot some "personality": different greetings, different fallback replies, and at least one slightly funny line for each intent. An LLM gets variety in a very different way: it generates new text instead of choosing from a list.


Step 5: Conversation State

A bot that doesn't remember anything can't hold a real conversation. A conversation has state: the user's name, what they said in the last message, things they've mentioned. Keep the state in a class (see oop).

python
import random
import re

class Conversation:
    def __init__(self):
        self.memory = {}              # any facts the user has told us
        self.history = []             # list of (speaker, text)
        self.last_intent = None

    def say(self, intent, *args):
        """Pick a response for an intent, format with args, log it."""
        replies = RESPONSES[intent]
        reply = random.choice(replies).format(*args)
        self.history.append(("bot", reply))
        self.last_intent = intent
        return reply

    def hear(self, text):
        self.history.append(("user", text))

        # Name capture
        m = re.search(r"\bmy name is (\w+)\b", text, re.IGNORECASE)
        if m:
            self.memory["name"] = m.group(1)
            return self.say("greet_named", m.group(1))

        # Mood capture: mention it again in the next turn
        m = re.search(r"\bi(?:'m| am) (sad|tired|happy|angry|bored)\b", text, re.IGNORECASE)
        if m:
            self.memory["mood"] = m.group(1)
            return self.say("acknowledge_mood", m.group(1))

        # Mention the remembered mood again
        if "still" in text.lower() and "mood" in self.memory:
            return self.say("mood_followup", self.memory["mood"])

        if re.search(r"\b(hi|hello|hey)\b", text, re.IGNORECASE):
            if "name" in self.memory:
                return self.say("greet_returning", self.memory["name"])
            return self.say("greet")

        return self.say("fallback")

RESPONSES = {
    "greet":              ["Hello!", "Hi there.", "Hey."],
    "greet_named":        ["Nice to meet you, {0}.", "Hi {0}, welcome."],
    "greet_returning":    ["Welcome back, {0}.", "Hi again {0}."],
    "acknowledge_mood":   ["Sorry to hear you're {0}.", "Why {0}?"],
    "mood_followup":      ["Earlier you said you were {0}. Still feeling that way?"],
    "fallback":           ["Tell me more.", "Interesting.", "Go on."],
}

# Demo
c = Conversation()
for msg in ["Hi", "My name is Bob", "I am tired", "Tell me a story",
            "are you still listening", "Hi"]:
    print(f"  you: {msg}")
    print(f"  bot: {c.hear(msg)}")
+ setup added so this can run · defines args
# Lightweight mock for objects whose attributes/methods aren't critical
class _AutoMock:
    def __init__(self, name='mock'): self._name = name
    def __getattr__(self, k): return _AutoMock(self._name + '.' + k)
    def __call__(self, *a, **kw):
        print('-> ' + self._name + '() called')
        return _AutoMock(self._name + '()')
    def __repr__(self): return '<mock ' + self._name + '>'
    def __str__(self): return '<mock ' + self._name + '>'
    def __bool__(self): return True
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __getitem__(self, k): return _AutoMock(self._name + '[...]')
    def __setitem__(self, k, v): pass
    def __enter__(self): return self
    def __exit__(self, *a): return False
    async def __aenter__(self): return self
    async def __aexit__(self, *a): return False
    def __add__(self, o): return self
    def __radd__(self, o): return self
    def __sub__(self, o): return self
    def __mul__(self, o): return self
    def __rmul__(self, o): return self
    def __truediv__(self, o): return self
    def __eq__(self, o): return isinstance(o, _AutoMock)
    def __hash__(self): return hash(self._name)
    def __lt__(self, o): return True
    def __le__(self, o): return True
    def __gt__(self, o): return False
    def __ge__(self, o): return False
    def __mro_entries__(self, bases): return (object,)

args = _AutoMock('args')

The Conversation instance holds the state. Two users get two Conversation objects, so there are no global variables, and one user's data never appears in another user's conversation. The memory dict holds facts about the user, and the history list is the transcript. Both will matter when we connect an LLM, because the LLM needs them as context.


Step 6: Polished Final Version (a Bot Class with an LLM-Stub Fallback)

A clean design keeps the intent definitions separate from the code that runs them. Each intent is a (pattern, response) pair. The response is either a string template or a callable (a function) that receives the match and the bot. With a callable, you can run any code you want: store a value, call a weather API, count something.

We also show where an LLM fallback would go, without calling a real LLM. When no pattern matches, an LLM is most useful. The stub returns a placeholder, so the lesson runs offline.

python
import random
import re

class Bot:
    def __init__(self, intents):
        # intents: list of (pattern, response) where response is str template
        # OR a callable(match, conversation) that returns a str
        self.intents = [(re.compile(p, re.IGNORECASE), r) for p, r in intents]
        self.history = []
        self.memory = {}

    def process(self, message):
        self.history.append(("user", message))

        for pattern, response in self.intents:
            m = pattern.search(message)
            if not m:
                continue
            if callable(response):
                reply = response(m, self)
            elif isinstance(response, list):
                reply = random.choice(response).format(*m.groups())
            else:
                reply = response.format(*m.groups())
            self.history.append(("bot", reply))
            return reply

        # No pattern matched. This is where an LLM becomes useful.
        reply = self.llm_fallback(message)
        self.history.append(("bot", reply))
        return reply

    def llm_fallback(self, message):
        """STUB. In production this calls a real LLM with system prompt,
        conversation history, and remembered facts. See the comment below
        for how the integration would look.
        """
        # Real implementation would look roughly like:
        #
        #   import anthropic
        #   client = anthropic.Anthropic()
        #   reply = client.messages.create(
        #       model="claude-haiku-4-5",
        #       max_tokens=256,
        #       system=self._build_system_prompt(),
        #       messages=[{"role": ("user" if s == "user" else "assistant"),
        #                  "content": t} for s, t in self.history],
        #   )
        #   return reply.content[0].text
        #
        # The stub keeps things runnable without API keys.
        return f"(LLM fallback would handle: {message!r})"

    def _build_system_prompt(self):
        facts = ", ".join(f"{k}={v}" for k, v in self.memory.items()) or "none"
        return f"You are a helpful assistant. Remembered facts about the user: {facts}."

# Intent definitions
def remember_name(m, bot):
    bot.memory["name"] = m.group(1)
    return f"Nice to meet you, {m.group(1)}."

def recall_name(m, bot):
    fallback = "... actually, you haven't told me."
    return f"You told me your name is {bot.memory.get('name', fallback)}"

INTENTS = [
    (r"\bmy name is (\w+)\b",           remember_name),
    (r"\bwhat(?:'s| is) my name\b",     recall_name),
    (r"\b(hi|hello|hey)\b",             ["Hello!", "Hi there.", "Hey."]),
    (r"\bhow are you\b",                ["Doing well.", "Stable as ever.", "Same as yesterday."]),
    (r"\b(bye|goodbye)\b",              ["Goodbye!", "See you."]),
]

# Demo session
bot = Bot(INTENTS)
for msg in [
    "Hi",
    "My name is Bob",
    "What is my name?",
    "How are you?",
    "Tell me a joke about octopuses",      # no pattern matches, so it goes to llm_fallback
    "bye",
]:
    print(f"  you: {msg}")
    print(f"  bot: {bot.process(msg)}")

Notice that Bot doesn't know about Anthropic, OpenAI or any other provider. llm_fallback is the only place that would talk to an LLM. Real production code would:

1. Move llm_fallback to a separate LLMClient class so you can swap providers.
2. Inject the client at construction: Bot(INTENTS, llm=AnthropicClient()).
3. Add caching, rate-limiting, and a streaming variant.

But the basic design is already in place.

Honest closing note on rule-based bots

Rule-based bots have a clear limit: they handle only the inputs you expected. If the user writes "yo what's good my dude" instead of "hi", the bot gives a fallback reply. Pattern matching is brittle: it doesn't handle new phrasing, doesn't understand synonyms and doesn't use context from earlier messages unless you write code for it.

LLMs have the opposite strengths and weaknesses. They handle new phrasing well, but each call costs money, and sometimes they invent wrong answers (this is called hallucination). A sensible mix is what we just built: patterns for the simple, predictable cases (greetings, simple commands, things you can test), and an LLM fallback for everything else.


Stretch Goals

1. Sentiment analysis: pip install textblob, then TextBlob(message).sentiment.polarity gives a score from -1 (negative) to +1 (positive). Choose the reply based on this score.
2. Real LLM integration: replace llm_fallback with a real anthropic.Anthropic().messages.create(...) call. Build the system prompt from self.memory. Stream the response with with client.messages.stream(...) as stream:.
3. Persistent memory across sessions: call json.dump(bot.memory, ...) when the program ends, and load the file when it starts. Then the bot still remembers your name a week later.
4. Voice I/O: use SpeechRecognition to turn speech into text, and pyttsx3 to turn the reply into speech. Both use the same bot.process().
5. Discord/Slack wrapper: with discord.py or slack-sdk, call bot.process() for every new message and post the reply. The bot logic doesn't change at all.


🎯 Your Turn: Memory: remember and recall

Extend the bot with two patterns so it can answer questions about facts the user has told it:

  • "my favourite colour is blue" → bot stores colour = blue
  • "what is my favourite colour?" → bot replies "Your favourite colour is blue."

Make it work for any topic: not only colour, but also food, animal or anything else. Pattern: my favourite (\w+) is (\w+).

python
import re
import random

class Bot:
    def __init__(self):
        self.memory = {}

    def remember(self, key, value):
        """Store a fact."""
        # TODO 1: save value under key in self.memory
        pass

    def recall(self, key):
        """Return the stored value, or None if unknown."""
        # TODO 2: look up key in self.memory
        pass

    def process(self, message):
        # TODO 3: regex 'my favourite (\w+) is (\w+)' then remember(slot, value),
        #         reply confirming
        # TODO 4: regex 'what(?:'s| is) my favourite (\w+)' then recall(slot),
        #         reply with value or "I don't know"
        # TODO 5: fall through to a default reply
        pass

# Test
bot = Bot()
print(bot.process("My favourite colour is blue"))
print(bot.process("My favourite food is pasta"))
print(bot.process("What is my favourite colour?"))
print(bot.process("What's my favourite food?"))
print(bot.process("What's my favourite drink?"))
print(bot.process("Hello!"))
Hint 1: Two regex patterns Statement: r"my favourite (\w+) is (\w+)" captures the slot and the value. Question: r"what(?:'s| is) my favourite (\w+)" captures the slot to look up.
Hint 2: Recall with a default self.memory.get(key) returns None for missing keys, which is useful for the "I don't know" branch.
Show full solution
python
import re

class Bot:
    def __init__(self):
        self.memory = {}

    def remember(self, key, value):
        self.memory[key] = value

    def recall(self, key):
        return self.memory.get(key)

    def process(self, message):
        m = re.search(r"my favourite (\w+) is (\w+)", message, re.IGNORECASE)
        if m:
            slot, value = m.group(1).lower(), m.group(2).lower()
            self.remember(slot, value)
            return f"Got it. Your favourite {slot} is {value}."

        m = re.search(r"what(?:'s| is) my favourite (\w+)", message, re.IGNORECASE)
        if m:
            slot = m.group(1).lower()
            value = self.recall(slot)
            if value:
                return f"Your favourite {slot} is {value}."
            return f"You haven't told me your favourite {slot} yet."

        return "Tell me about your favourites."

bot = Bot()
for msg in [
    "My favourite colour is blue",
    "My favourite food is pasta",
    "What is my favourite colour?",
    "What's my favourite food?",
    "What's my favourite drink?",
    "Hello!",
]:
    print(f"  you: {msg}")
    print(f"  bot: {bot.process(msg)}")

remember and recall work like a key-value store, such as Redis, localStorage or a dict. The next steps (saving to disk, sharing across sessions, syncing to a database) follow the same pattern.


What You Learned

  • Patterns as data: a list of (regex, response) tuples is much easier to maintain than long if chains.
  • re.search with groups: capture parts of the user's message and put them into reply templates with .format(*m.groups()).
  • random.choice for a simple personality: several replies for each intent.
  • Conversation state in a class: memory and history as instance attributes, and one bot instance for each user.
  • Where an LLM fallback fits: one llm_fallback(message) method that production code would connect to Anthropic or OpenAI.
  • The hybrid pattern: rules for the simple, predictable cases, and an LLM for everything else.

You now understand how chatbots developed from ELIZA (1966) to today's hybrid bots. You can use the rule-based part right now. For the LLM part, you need one pip install and an API key.

← Back to Academy

Found this useful? Share it with a friend who’s learning Python.