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 they were built before neural networks: regex patterns, response templates, and a sprinkle of state. Then we'll mark exactly where you'd swap in an LLM call — without actually making one — so you understand both halves of the modern stack.

What you'll practice: 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 Bare-Bones Version

Twelve lines, no imports. Pure if/elif. Crude — but it works, and every chatbot ever shipped started here.

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 the input space is tiny. Add five more intents and the if chain becomes unreadable — and you still can't handle "hi there!" because exact-match in ("hi",) won't fire on "hi there".

We need patterns.


Step 2 — Pattern-Based Responses

Move the rules into data: a list of (regex, response) tuples. Iterate; first match wins. See regex if \b (word boundary) is new.

python
import re

RULES = [
    (r"\b(hi|hello|hey)\b",      "Hello there!"),
    (r"\bhow are you\b",         "I'm a string of code, so — 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)}")

Adding a new intent is now a one-line change — append a tuple to RULES. The code stays the same. Patterns as data, code as the interpreter — same principle as the Mad Libs templates earlier in the track.

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


Step 3 — Response Templates with Captures

The bot gets interesting once it can quote you back. Capture groups in the regex, format into the response.

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. The (?:...) is a non-capturing group — we want to match "like" or "love" without that being one of the substitutions.

This is the trick ELIZA used in 1966: a pattern like I am (.+) with a reply Why are you {0}? produces remarkably human-feeling responses. ELIZA fooled people into thinking it was a real therapist. The patterns were doing all the work.


Step 4 — Multiple Possible Responses

A bot that says "Hello!" to every "hi" gets robotic fast. Make each rule a list of replies, 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 things buy you "personality" cheaply: variety in greetings, variety in fallbacks, and at least one slightly cheeky line per intent. ChatGPT does this too, just with a billion parameters instead of a list of strings.


Step 5 — Conversation State

A bot that doesn't remember anything is amnesiac. A conversation has state: the user's name, what they said last turn, things they've mentioned. Wrap it in a class — see oop.

python
import random
import re

class Conversation:
    def __init__(self):
        self.memory = {}              # arbitrary 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 — call back to it 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))

        # Call back to remembered mood
        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 is the state. Two users get two Conversation objects — no globals, no leakage. The memory dict is the user's "facts" and the history list is the transcript. Both will matter when we wire up an LLM, because the system prompt will need them.


Step 6 — Polished Final: A Bot Class with an LLM-Stub Fallback

A clean design separates intent definitions from the engine. Each intent is a (pattern, response) pair where the response is either a string template or a callable taking the match and the conversation. The callable lets you do arbitrary logic — store a value, call a weather API, count something.

And we lay out the LLM-fallback shape without actually calling one. When no pattern matches, an LLM would shine. 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) -> 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 earns its keep.
        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 the integration shape.
        """
        # 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):
    return f"You told me your name is {bot.memory.get('name', '... actually, you haven\\'t told me.')}"

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 — falls through to llm_fallback
    "bye",
]:
    print(f"  you: {msg}")
    print(f"  bot: {bot.process(msg)}")

Notice the shape: Bot doesn't know about Anthropic, OpenAI, or any specific provider. The llm_fallback is a single seam. 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 architecture is already right.

Honest closing note on rule-based bots

Rule-based bots have a hard ceiling. They handle exactly the inputs you anticipated. The first unanticipated phrasing — "yo what's good my dude" instead of "hi" — and you're in fallback land. Pattern matching is brittle: it doesn't generalise, doesn't understand synonyms, doesn't read context across turns unless you wire it manually.

LLMs invert this: they generalise effortlessly but cost money per call and occasionally hallucinate. The sensible hybrid is what we just built — patterns for the cheap deterministic stuff (greetings, simple commands, things you can test) and LLM fallback for the open-ended rest. Most production "AI chatbots" are this hybrid; the marketing just leaves out the regex half.


Stretch Goals

1. Sentiment analysis — pip install textblob, then TextBlob(message).sentiment.polarity gives a score from -1 (negative) to +1 (positive). Branch responses on it.
2. Real LLM integration — replace llm_fallback with an actual 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 — json.dump(bot.memory, ...) on shutdown, load on start. Now the bot remembers your name a week later.
4. Voice I/O — SpeechRecognition for mic-to-text input, pyttsx3 for text-to-speech output. Wrap the same bot.process().
5. Discord/Slack wrapper — discord.py or slack-sdk; on every message event, call bot.process() 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."

Generalise the slot: not just colour, but food, animal, anything. 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+)' → remember(slot, value),
        #         reply confirming
        # TODO 4: regex 'what(?:'s| is) my favourite (\w+)' → 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 — handy 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)}")

The remember/recall split is the same shape as a key-value store — Redis, localStorage, dict. Once you see the pattern, the next step (persist to disk, share across sessions, sync to a database) is mechanical.


What You Learned

  • Patterns-as-data — a list of (regex, response) tuples is far more maintainable than nested if chains.
  • re.search with groups — capture parts of the user's message and slot them into reply templates with .format(*m.groups()).
  • random.choice for cheap personality — multiple replies per intent.
  • Conversation state in a class — memory and history as instance attributes, one bot instance per user.
  • The integration shape of an LLM fallback — a single llm_fallback(message) seam that production code would point at Anthropic or OpenAI.
  • The hybrid pattern — rules for the cheap deterministic stuff, LLM for the open-ended rest. Most "AI" products in the wild are this.

You now understand the full arc from ELIZA (1966) to today's hybrid bots. The patterns half is yours to ship right now; the LLM half is one pip install and an API key away.

← Back to Academy