Project: Rule-Based Chatbot
1 · The lesson
readYou'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.
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.
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.
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.
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.
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.
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+).
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
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 nestedifchains. re.searchwith groups — capture parts of the user's message and slot them into reply templates with.format(*m.groups()).random.choicefor cheap personality — multiple replies per intent.- Conversation state in a class —
memoryandhistoryas 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.