# Build a Chatbot in Python From Scratch: No API Keys, No AI Libraries

Most "build a chatbot" tutorials are really "call someone else's API" tutorials. This one isn't. We build **Nova**, a small chatbot that learns from examples, using only Python's standard library for the AI part, plus FastAPI to serve it to a web chat page.

No API key, no paid service, no model download. Every line of the classifier fits on one screen.

%[https://youtu.be/kdIUMAgwrcU]

Full source code: https://github.com/rainyreaksmey/build-chatbot-from-scratch

Every number in this post comes from running that repo.

> Nova is an **intent** chatbot, not a large language model. It doesn't write new sentences. It recognizes what you mean and picks a matching answer. That's exactly how many real FAQ and support bots work.

## 1. How an intent chatbot works

Three steps:

1. **Classify** the message into an intent (`greeting`, `goodbye`, `python`, `training`…).
2. **Pick** one of that intent's responses.
3. If the model isn't confident enough, say so (a **fallback**).

The whole bot is: training data → word counts → probabilities.

## 2. The training data: intents.json

You teach Nova by writing examples, not code:

```json
{
  "tag": "greeting",
  "patterns": ["hello", "hi", "hey", "good morning", "good evening", "how are you", "hello chatbot"],
  "responses": ["Hello! What would you like to learn?", "Hi! Ask me about Python or AI."]
}
```

`patterns` are example messages. `responses` are what Nova may answer. The starter file has **10 intents, 56 examples and 90 unique words.**

## 3. Tokenization: text to words

The model can't work with sentences directly, so we split them into lowercase words with one regular expression:

```python
TOKEN_RE = re.compile(r"[a-z0-9']+")

def tokenize(text: str) -> list[str]:
    return TOKEN_RE.findall(text.lower())
```

```python
>>> tokenize("Hi Nova! How's it going?")
['hi', 'nova', "how's", 'it', 'going']
```

Punctuation disappears, capitals disappear, and apostrophes stay so "how's" remains one word.

## 4. Training is just counting

"Training" Naive Bayes means counting three things for each intent:

- how many examples it has
- how often each word appears in them
- how many words it has in total

```python
for pattern in patterns:
    tokens = tokenize(str(pattern))
    self.document_count += 1
    self.intent_documents[tag] += 1
    self.token_counts[tag].update(tokens)
    self.total_tokens[tag] += len(tokens)
    self.vocabulary.update(tokens)
```

For `greeting` in the starter data: **7 examples, 12 words in total, and "hello" appears 2 times.** That's the whole trained model. It's saved as plain JSON, so you can open it and read it.

## 5. The math: Naive Bayes

For a message, we want the intent with the highest probability given its words. Bayes' rule says that's proportional to:

```
P(intent) × P(word₁ | intent) × P(word₂ | intent) × …
```

The "naive" part is assuming the words are independent. It's not really true, but it works surprisingly well for short messages.

**The prior**, how common the intent is:

```
P(intent) = (examples in intent + 1) / (all examples + number of intents)
```

**The likelihood**, how typical a word is for the intent:

```
P(word | intent) = (count of word in intent + 1) / (words in intent + vocabulary size)
```

Real numbers for "hello" in `greeting`: (2 + 1) / (12 + 90) = **3 / 102 ≈ 0.029**.

### Why the "+1"? (Laplace smoothing)

Without it, a word the intent has never seen gets probability 0, and multiplying by 0 wipes out the whole score. One unknown word, like "Nova", would make every intent impossible. Adding 1 to every count gives unseen words a small chance instead of zero.

### Why logs?

Multiplying many small probabilities gives numbers so tiny that a computer rounds them to zero. So we add logarithms instead of multiplying:

```python
score = math.log(prior)
for token in tokens:
    likelihood = (self.token_counts[intent][token] + 1) / denominator
    score += math.log(likelihood)
```

## 6. From scores to confidence: softmax

The log scores aren't percentages. To turn them into probabilities that add up to 1, we use softmax, subtracting the highest score first so `exp()` never overflows:

```python
highest = max(scores.values())
exponentials = {name: math.exp(score - highest) for name, score in scores.items()}
normalizer = sum(exponentials.values())
probabilities = {name: value / normalizer for name, value in exponentials.items()}
```

The top probability is Nova's **confidence**. If it's below the threshold (0.25), Nova falls back instead of guessing.

## 7. The cold start problem (real results)

With the starter data, here's what Nova actually does:

- "what is python" → `python`, **59.5%**
- "hello" → `greeting`, **30.3%**
- "Hi Nova!" → **fallback** (greeting was top at only **23.4%**, under the 25% threshold)
- "What's the weather in Paris?" → **fallback**, 19.3%, which is correct, since Nova knows nothing about weather

Why does "Hi Nova!" fail? "Nova" never appears in the training data, so it adds nothing useful, and "hi" is only one short example among 56.

**The fix is data, not code.** I added four greeting patterns, "hi nova", "hey nova", "hello nova" and "hi there nova", and retrained:

- "Hi Nova!" → `greeting`, **79.5%**

Same algorithm, four more lines of JSON, from 23% to 79%. In the video, I keep improving the examples and Nova reaches 96% on it. That's the main lesson of the whole project: **a small model with good examples beats a clever model with bad ones.**

## 8. Serve it with FastAPI

The API is three endpoints: health, chat and retrain.

```python
class ChatRequest(BaseModel):
    message: str = Field(min_length=1, max_length=1000)

@app.post("/api/chat", response_model=ChatResponse)
def chat(request: ChatRequest) -> ChatResponse:
    message = request.message.strip()
    if not message:
        raise HTTPException(status_code=422, detail="Message cannot be blank")
    with model_lock:
        reply, prediction = bot.reply(message)
    return ChatResponse(reply=reply, intent=prediction.intent,
                        confidence=round(prediction.confidence, 4))
```

Pydantic rejects empty or huge messages for free, and the lock keeps a retrain from swapping the model in the middle of a reply. A small HTML/JS page in `web/` calls `/api/chat` and shows the answer with its intent and confidence.

## Run it yourself

```bash
git clone https://github.com/rainyreaksmey/build-chatbot-from-scratch
cd build-chatbot-from-scratch
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python train.py
uvicorn app:app --reload
```

Open http://127.0.0.1:8000 to chat, or http://127.0.0.1:8000/docs for the interactive API.

To teach Nova something new, add an intent to `data/intents.json` with several differently-worded patterns, then run `python train.py` again. Don't expose `/api/train` publicly without authentication.

## Recap

- An intent chatbot = classify the message, then pick a response.
- Training Naive Bayes is just counting words.
- Laplace smoothing (+1) keeps unknown words from zeroing everything out.
- Logs prevent tiny-number underflow, and softmax turns scores into a confidence.
- A fallback threshold is better than a confident wrong answer.
- Better examples fixed the bot, not more code.

Full walkthrough with every line typed live: https://youtu.be/kdIUMAgwrcU

*What should Nova learn next? Tell me in the comments.*

---

*Drafted with AI assistance. The code and numbers come from the recorded run. I make AI, coding and tech tutorials on [RainyTech](https://www.youtube.com/@RainyTechLab).*

