๐จโ๐ป Developer Guide - AI Quiz Bot
Comprehensive guide for developers to understand, modify, and extend the bot code.
๐ Table of Contents
- Code Overview
- Main Components
- Function Reference
- Data Flow
- How to Modify
- Adding Features
- Best Practices
๐๏ธ Code Overview
File Structure
bot.py - Single file containing all bot logic (~400 lines)
The bot is organized into logical sections:
- Imports & Configuration - Load dependencies and environment
- API Functions - Communication with Ollama Cloud
- Message Handlers - Process user input
- Quiz Generation - Create questions
- Bot Initialization - Setup and start bot
Technology Stack
- Framework: python-telegram-bot (async)
- HTTP Client: httpx (async HTTP requests)
- PDF Processing: PyPDF2
- API: Ollama Cloud (REST API)
- Configuration: python-dotenv
๐ง Main Components
1. Configuration Section (Lines 1-30)
import os
import json
import asyncio
import logging
from io import BytesIO
from dotenv import load_dotenv
from telegram import Update
from telegram.ext import Application, MessageHandler, filters, ContextTypes
import PyPDF2
import base64
import httpx
# Load .env file
load_dotenv()
# Configure logging
logging.basicConfig(
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
level=logging.INFO
)
logger = logging.getLogger(__name__)
# Environment variables
TELEGRAM_BOT_TOKEN = os.getenv("TELEGRAM_BOT_TOKEN")
OLLAMA_HOST = os.getenv("OLLAMA_HOST", "http://localhost:11434")
OLLAMA_API_KEY = os.getenv("OLLAMA_API_KEY", "")
VISION_MODEL = os.getenv("VISION_MODEL", "llava")
CHAT_MODEL = os.getenv("CHAT_MODEL", "mistral")
Key Points:
- All configuration comes from
.envfile - Logging is configured at INFO level
- Default values provided for optional settings
httpxandbase64used for API communication
2. Image Extraction Function (Lines 32-60)
async def extract_text_from_image(image_bytes: bytes) -> str:
"""Extract text from an image using Ollama vision API."""
try:
# Convert image to base64 for API transmission
image_base64 = base64.b64encode(image_bytes).decode('utf-8')
# Build request payload
payload = {
"model": VISION_MODEL,
"messages": [{
"role": "user",
"content": "Just transcribe all the text...",
"images": [image_base64]
}],
"stream": False
}
# Add auth header
headers = {}
if OLLAMA_API_KEY:
headers["Authorization"] = f"Bearer {OLLAMA_API_KEY}"
# Make async HTTP request
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{OLLAMA_HOST}/api/chat",
json=payload,
headers=headers
)
response.raise_for_status()
result = response.json()
return result['message']['content']
except Exception as e:
logger.error(f"Error extracting text from image: {e}")
raise
What it does:
- Encodes image to base64 (required by Ollama API)
- Prepares request with model and prompt
- Adds authentication header if API key exists
- Makes async POST request to Ollama
- Extracts and returns text from response
Customization:
- Change prompt in "content" field for different text extraction
- Modify timeout (120.0 seconds) if needed
- Add image preprocessing before base64 encoding
3. PDF Extraction Function (Lines 63-73)
def extract_text_from_pdf(pdf_bytes: bytes) -> str:
"""Extract text from a PDF file."""
try:
pdf_reader = PyPDF2.PdfReader(BytesIO(pdf_bytes))
text = ""
for page in pdf_reader.pages:
text += page.extract_text() + "\n"
return text.strip()
except Exception as e:
logger.error(f"Error extracting text from PDF: {e}")
raise
What it does:
- Creates BytesIO stream from PDF bytes
- Reads all pages sequentially
- Extracts text from each page
- Combines into single string
- Returns trimmed result
Limitations:
- Only works with text-based PDFs (not scanned images)
- Scanned PDFs need OCR (use image extraction instead)
4. Quiz Generation Function (Lines 76-138)
async def generate_quiz_questions(text: str, num_questions: int = 5) -> list:
"""Generate quiz questions from text using Ollama API."""
system_prompt = """You are a strict Quiz Generator.
Rules:
1. Output ONLY the questions and answers.
2. Do NOT provide any conversational text...
4. Follow the required format exactly."""
user_prompt = f"""Based on the text I provide, generate {num_questions} multiple-choice quiz questions.
You must output valid JSON only. Do not wrap it in markdown blocks (like ```json).
The output format must be a strictly valid JSON array of objects, like this:
[
{{
"question": "Which command creates a branch?",
"options": ["git merge", "git branch", "git pull", "git push"],
"correct_option_id": 1
}}
]
(Note: 'correct_option_id' is the index of the correct answer in the options array: 0 for A, 1 for B, 2 for C, 3 for D).
Text to analyze:
{text}"""
try:
payload = {
"model": CHAT_MODEL,
"messages": [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
],
"stream": False,
"format": "json"
}
headers = {}
if OLLAMA_API_KEY:
headers["Authorization"] = f"Bearer {OLLAMA_API_KEY}"
async with httpx.AsyncClient(timeout=120.0) as client:
response = await client.post(
f"{OLLAMA_HOST}/api/chat",
json=payload,
headers=headers
)
response.raise_for_status()
result = response.json()
output = result['message']['content']
# Clean up markdown if AI added it
clean_json = output.replace('```json', '').replace('```', '').strip()
# Parse JSON
questions = json.loads(clean_json)
return questions
except json.JSONDecodeError as e:
logger.error(f"JSON Parse failed: {e}")
raise ValueError(f"Failed to parse quiz questions: {e}")
except Exception as e:
logger.error(f"Error generating quiz: {e}")
raise
How it works:
- System prompt instructs AI to generate specific format
- User prompt requests X questions in JSON format
- Sends request to Ollama Chat API
- Strips markdown code blocks if AI added them
- Parses and returns JSON array
JSON Output Format:
[
{
"question": "Question text here?",
"options": ["Option A", "Option B", "Option C", "Option D"],
"correct_option_id": 0
}
]
How to Modify:
- Change number of options (currently 4)
- Modify difficulty/style in system prompt
- Add new fields (e.g., "explanation": "...")
- Change question count validation (1-20)
5. Poll Sending Function (Lines 141-156)
async def send_quiz_poll(context: ContextTypes.DEFAULT_TYPE,
chat_id: int, question_data: dict):
"""Send a quiz poll to Telegram."""
try:
await context.bot.send_poll(
chat_id=chat_id,
question=question_data['question'][:300],
options=question_data['options'][:10],
type='quiz',
correct_option_id=question_data['correct_option_id'],
is_anonymous=False
)
except Exception as e:
logger.error(f"Error sending poll: {e}")
raise
Parameters:
chat_id- Telegram chat IDquestion- Max 300 chars (Telegram limit)options- Max 10 options (Telegram limit)type='quiz'- Makes it a quiz (reveals answer)is_anonymous=False- Shows who answeredcorrect_option_id- Index of correct answer
6. Question Count Parser (Lines 159-175)
def parse_num_questions(caption: str) -> int:
"""Parse number of questions from caption."""
if not caption:
return 5 # Default
import re
numbers = re.findall(r'\d+', caption)
if numbers:
num = int(numbers[0])
return min(max(num, 1), 20) # Limit: 1-20
return 5
Logic:
- If no caption, return default (5)
- Extract first number from caption using regex
- Validate it's between 1-20
- Return constrained value
How to Modify:
- Change default:
return 5โreturn 10 - Change limits:
min(max(num, 1), 20)โmin(max(num, 2), 50)
๐ Function Reference
Message Handlers
handle_file(update, context) (Lines 178-270)
Processes images and PDFs sent by users.
Flow:
1. Get file (image or document)
2. Detect file type from extension
3. Download file bytes
4. Extract text (vision API for images, PyPDF2 for PDFs)
5. Parse number of questions from caption
6. Generate quiz questions
7. Send each question as poll
8. Send completion message
File Type Detection:
- Images:
.jpg,.jpeg,.png,.webp - PDFs:
.pdf - Others: Rejected with error message
Status Messages:
- "๐ธ ุฌุงุฑู ุชุญููู ุงูุตูุฑุฉ..." - Processing image
- "๐ ุฌุงุฑู ุงุณุชุฎุฑุงุฌ ุงููุต..." - Processing PDF
- "๐ง ุฌุงุฑู ุชูููุฏ X ุงุณุฆูุฉ..." - Generating questions
handle_text(update, context) (Lines 273-320)
Processes text messages from users.
Flow:
1. Get text from message
2. Validate minimum length (20 chars)
3. Check if first line is a number
4. If number found, remove it and use as question count
5. Generate quiz questions
6. Send each question as poll
7. Send completion message
Validation:
- Minimum text: 20 characters
- Question count: 1-20 (parsed from first line)
- Default: 5 questions
Command Handlers
start_command(update, context) (Lines 323-343)
Sends welcome message with usage instructions.
Content:
- ๐ Welcome message
- ๐ผ๏ธ Image usage
- ๐ PDF usage
- โ๏ธ Text usage
- โ๏ธ Settings
- ๐ Call to action
help_command(update, context) (Lines 346-365)
Sends FAQ and common questions.
FAQs Covered:
- Question count limits (1-20)
- Language support
- Missing answers troubleshooting
- Speed issues
- How to start
๐ Data Flow
Complete User Request Flow
User sends Image
โ
Telegram receives message
โ
handle_file() triggered
โ
Get file ID from Telegram API
โ
Download file bytes
โ
Detect file type (image/PDF)
โ
IF IMAGE:
extract_text_from_image()
โโ Convert to base64
โโ Call Ollama Vision API
โโ Get extracted text
ELSE IF PDF:
extract_text_from_pdf()
โโ Read PDF pages
โโ Extract text per page
โโ Combine text
โ
Parse caption for question count
โ
Call generate_quiz_questions()
โโ Build prompt with text and count
โโ Call Ollama Chat API
โโ Parse JSON response
โโ Get list of questions
โ
For each question:
send_quiz_poll()
โโ Call Telegram send_poll API
โโ Add 1 second delay
โ
Send completion message
โ
Operation complete
๐ ๏ธ How to Modify
Changing Models
Edit .env:
VISION_MODEL=different-model-name
CHAT_MODEL=different-model-name
Or change defaults in bot.py:
VISION_MODEL = os.getenv("VISION_MODEL", "new-default-model")
CHAT_MODEL = os.getenv("CHAT_MODEL", "new-default-model")
Changing Question Format
In generate_quiz_questions(), modify the prompt:
user_prompt = f"""...
The output format must be a strictly valid JSON array of objects:
[
{{
"question": "Question text?",
"options": ["A", "B", "C", "D"],
"correct_option_id": 0,
"explanation": "Why is this correct?" # NEW FIELD
}}
]
..."""
Then update parsing:
questions = json.loads(clean_json)
# Now questions contain explanation field too
Changing Response Language
Modify prompts in both functions:
system_prompt = """ุฃูุช ู
ููุฏ ุฃุณุฆูุฉ ุฐูู.
ุงูููุงููู:
1. ุฃุทูู ููุท ุงูุฃุณุฆูุฉ ูุงูุฅุฌุงุจุงุช..."""
Adding Database Support
Import database library:
import sqlite3 # or sqlalchemy, mongodb, etc.
Add in handle_file() after quiz generation:
# Save quiz to database
db.save_quiz(
user_id=message.from_user.id,
questions=questions,
source_type='image',
timestamp=datetime.now()
)
Adding User Analytics
Track in message handlers:
# Count API calls
STATS = {
"total_images": 0,
"total_pdfs": 0,
"total_texts": 0,
"total_questions": 0
}
# In handle_file():
STATS["total_images"] += 1
STATS["total_questions"] += len(questions)
๐ Adding Features
Feature 1: Question Difficulty Levels
# Modify prompt
user_prompt = f"""...
Difficulty level: {difficulty} # easy, medium, hard
Generate questions appropriate for {difficulty} level...
"""
# Parse difficulty from caption
def parse_difficulty(caption: str) -> str:
if "easy" in caption.lower():
return "easy"
elif "hard" in caption.lower():
return "hard"
return "medium"
Feature 2: Export Questions as PDF
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
def export_questions_to_pdf(questions: list) -> bytes:
buffer = BytesIO()
c = canvas.Canvas(buffer, pagesize=letter)
for i, q in enumerate(questions, 1):
c.drawString(50, 700 - i*20, f"{i}. {q['question']}")
for opt_idx, opt in enumerate(q['options']):
marker = "โ" if opt_idx == q['correct_option_id'] else " "
c.drawString(70, 680 - i*20, f"{marker} {opt}")
c.save()
buffer.seek(0)
return buffer.getvalue()
Feature 3: User Statistics
class UserStats:
def __init__(self):
self.stats = {}
def record_quiz(self, user_id: int, num_questions: int):
if user_id not in self.stats:
self.stats[user_id] = {"quizzes": 0, "questions": 0}
self.stats[user_id]["quizzes"] += 1
self.stats[user_id]["questions"] += num_questions
def get_user_stats(self, user_id: int) -> dict:
return self.stats.get(user_id, {})
stats = UserStats()
# In handle_file():
stats.record_quiz(message.from_user.id, len(questions))
๐ Best Practices
1. Error Handling
Always use try-except:
try:
result = await some_api_call()
except httpx.TimeoutException:
logger.error("API timeout")
# Handle gracefully
except Exception as e:
logger.error(f"Unexpected error: {e}")
# Notify user
2. Async/Await
Use async for all API calls:
# โ
Good
async def my_function():
async with httpx.AsyncClient() as client:
response = await client.get(url)
# โ Avoid blocking calls in handlers
requests.get(url) # This blocks the bot!
3. Logging
Log important events:
logger.info(f"Processing image for user {user_id}")
logger.error(f"Failed to extract text: {e}")
logger.debug(f"Generated questions: {questions}")
4. Input Validation
Always validate user input:
if not text or len(text) < 20:
return None # Invalid
num = parse_num_questions(caption)
if not 1 <= num <= 20:
num = 5 # Reset to default
5. Resource Cleanup
Close resources properly:
async with httpx.AsyncClient() as client:
response = await client.post(url, json=payload)
# Automatically closed when exiting context
6. Environment Variables
Never hardcode credentials:
# โ Bad
API_KEY = "secret123"
# โ
Good
API_KEY = os.getenv("API_KEY")
if not API_KEY:
raise ValueError("API_KEY not set in .env")
๐ Related Files
- README.md - User documentation
- ARCHITECTURE.md - System design and API details
- .env - Configuration (keep private!)
- requirements.txt - Dependencies
๐ Support
For questions or issues:
- Check error logs
- Review function docstrings
- Consult this guide
- Test with simple examples first
Last Updated: February 2026
Code Version: 1.0