DEV Community

RainyTech
RainyTech

Posted on AI-assisted

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.

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:

{
  "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."]
}
Enter fullscreen mode Exit fullscreen mode

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:

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

def tokenize(text: str) -> list[str]:
    return TOKEN_RE.findall(text.lower())
Enter fullscreen mode Exit fullscreen mode
>>> tokenize("Hi Nova! How's it going?")
['hi', 'nova', "how's", 'it', 'going']
Enter fullscreen mode Exit fullscreen mode

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
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)
Enter fullscreen mode Exit fullscreen mode

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) × …
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

P(word | intent) = (count of word in intent + 1) / (words in intent + vocabulary size)
Enter fullscreen mode Exit fullscreen mode

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:

score = math.log(prior)
for token in tokens:
    likelihood = (self.token_counts[intent][token] + 1) / denominator
    score += math.log(likelihood)
Enter fullscreen mode Exit fullscreen mode

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:

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()}
Enter fullscreen mode Exit fullscreen mode

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.

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))
Enter fullscreen mode Exit fullscreen mode

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

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
Enter fullscreen mode Exit fullscreen mode

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.

Top comments (0)