Ever feel overwhelmed while learning something new? Like you're drowning in information but not actually absorbing anything? We've all been there. Wouldn't it be awesome to have a personalized study companion that understands your level and explains things in a way that clicks? That's exactly what we're going to build together.
This tutorial will show you how to combine the BotHub API with PyQt5 to create an interactive and adaptable learning tool. It's not just another chatbot; it's more like a personal tutor, available 24/7.
Prepping Your Workspace
Before we start building, let's gather our tools. We'll need a few key Python libraries:
import os
import datetime
import json
from dataclasses import dataclass
from typing import List, Dict
from openai import OpenAI
from dotenv import load_dotenv
from PyQt5.QtCore import Qt, QThread, pyqtSignal
from PyQt5.QtGui import QMovie
from PyQt5.QtWidgets import (QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QLineEdit, QTextEdit, QRadioButton, QButtonGroup, QPushButton, QGroupBox, QListWidget, QListWidgetItem, QTabWidget, QFileDialog, QComboBox, QCheckBox, QMessageBox, QDialogButtonBox, QSpinBox, QFormLayout, QDialog, QDateEdit)
Think of these libraries as different parts of your toolkit. Some handle the basics, like file management (os
), timekeeping (datetime
), and data handling (json
). Others, like dataclasses
and typing
, help us write clean, organized code. The real magic happens with openai
, which lets us tap into the power of AI. dotenv
keeps our sensitive information (like API keys) secure. And finally, PyQt5
helps us create a beautiful and intuitive user interface.
Crafting User Requests
To communicate with our AI, we'll create a UserRequest
class. This helps organize the information the user provides:
@dataclass
class UserRequest:
query: str
user_level: str
preferences: Dict
Using the handy @dataclass
decorator, we define three key pieces of information: the user's query
(what they're asking), their user_level
(beginner, intermediate, or advanced), and their preferences
(like how long they want the response to be). This neatly packages everything into a single object.
Remembering User Sessions
To make the learning experience truly personalized, we need to remember what the user has done and how they like to learn. That's where the UserSession class comes in:
class UserSession:
def __init__(self):
self.history: List[Dict] = []
self.preferences: Dict = {}
self.level: str = "beginner"
def add_to_history(self, query, response):
self.history.append({"query": query, "response": response, "timestamp": datetime.datetime.now().isoformat()})
def update_preferences(self, new_preferences):
self.preferences.update(new_preferences)
A UserSession
keeps track of the conversation history
, the user's preferences
, and their current level
. It's like having a dedicated assistant who remembers everything and adapts to the user's needs.
The Brains of the Operation: EducationalAssistant
The EducationalAssistant
class is the heart of our application. It's responsible for interacting with the BotHub API:
class EducationalAssistant:
def __init__(self):
load_dotenv()
self.client = OpenAI(api_key=os.getenv('BOTHUB_API_KEY'), base_url='https://bothub.chat/api/v2/openai/v1')
self.session = UserSession()
def generate_prompt(self, request):
prompt = f"""As an educational assistant, provide a response for a {request.user_level} level student.
Query: {request.query}\n"""
if request.preferences:
prompt += "Consider these preferences:\n"
for key, value in request.preferences.items():
if key == "response_length":
prompt += f"Desired Length: Approximately {value} words\n"
elif key == "include_examples" and value:
prompt += "Include Examples: Yes\n"
else:
prompt += f"{key.capitalize()}: {value}\n"
prompt += "Please provide a detailed explanation."
return prompt
def generate_text_response(self, request):
try:
response = self.client.chat.completions.create(
model="claude-3.5-sonnet", // u can use any model in "Models available" on BotHub
messages=[
{"role": "system", "content": "You are an educational assistant."},
{"role": "user", "content": self.generate_prompt(request)}
]
)
return response.choices[0].message.content
except Exception as e:
return f"Error generating text response: {e}"
This class handles a few crucial tasks. First, it initializes the connection to BotHub using your API key (we talked about it previously). It also sets up a UserSession
to keep track of the interaction. The generate_prompt
method takes the user's request and transforms it into a prompt the API can understand. Finally, generate_text_response
sends the prompt to the API and retrieves the AI-generated answer.
Smooth and Responsive: GenerateResponseThread
To avoid making the user wait while the AI is thinking, we'll use a separate thread for API calls:
class GenerateResponseThread(QThread):
finished = pyqtSignal(str)
def __init__(self, assistant, request):
super().__init__()
self.assistant = assistant
self.request = request
def run(self):
response = self.assistant.generate_text_response(self.request)
self.finished.emit(response)
This GenerateResponseThread
, based on PyQt5's QThread
, runs the API request in the background, ensuring that the user interface remains responsive.
Personalizing the Experience
Everyone learns differently. To cater to individual preferences, we'll create a PreferencesDialog
:
class PreferencesDialog(QDialog):
def __init__(self, parent=None, preferences=None):
super().__init__(parent)
self.setWindowTitle("Preferences")
self.preferences = preferences or {}
layout = QVBoxLayout()
form_layout = QFormLayout()
self.tone_combo = QComboBox()
self.tone_combo.addItems(["Formal", "Informal"])
form_layout.addRow("Tone of Voice:", self.tone_combo)
self.length_spin = QSpinBox()
self.length_spin.setMinimum(50)
self.length_spin.setMaximum(1000)
self.length_spin.setSingleStep(50)
form_layout.addRow("Response Length (words):", self.length_spin)
self.examples_check = QCheckBox("Include Examples")
form_layout.addRow(self.examples_check)
layout.addLayout(form_layout)
button_box = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
button_box.accepted.connect(self.accept)
button_box.rejected.connect(self.reject)
layout.addWidget(button_box)
self.setLayout(layout)
self.set_initial_values()
def set_initial_values(self):
if self.preferences:
self.tone_combo.setCurrentText(self.preferences.get("tone_of_voice", "Formal"))
self.length_spin.setValue(self.preferences.get("response_length", 200))
self.examples_check.setChecked(self.preferences.get("include_examples", True))
def get_preferences(self):
return {
"tone_of_voice": self.tone_combo.currentText(),
"response_length": self.length_spin.value(),
"include_examples": self.examples_check.isChecked(),
"learning_style": self.preferences.get("learning_style", "Visual"),
"topics_of_interest": self.preferences.get("topics_of_interest", [])
}
This dialog allows users to customize settings like the AI's tone of voice, the desired response length, and whether to include examples. This level of customization ensures a more engaging and effective learning experience.
Building the Interface
Finally, let's create the user interface with the EducationalAssistantGUI
class:
class EducationalAssistantGUI(QWidget):
def __init__(self):
super().__init__()
self.assistant = EducationalAssistant()
self.loading_movie = QMovie("path/to/loading.gif")
self.initUI()
self.history = []
self.history_file = "chat_history.json"
self.load_history()
self.assistant.session.preferences = self.load_preferences()
def initUI(self):
self.setWindowTitle("Educational Assistant")
layout = QVBoxLayout()
tabs = QTabWidget()
chat_tab = QWidget()
history_tab = QWidget()
tabs.addTab(chat_tab, "Chat")
tabs.addTab(history_tab, "History")
chat_layout = QVBoxLayout()
query_group = QGroupBox("Query")
query_layout = QVBoxLayout()
self.query_text = QTextEdit()
query_layout.addWidget(self.query_text)
query_group.setLayout(query_layout)
layout.addWidget(query_group)
level_group = QGroupBox("User Level")
level_layout = QHBoxLayout()
self.level_selection = QButtonGroup()
beginner_button = QRadioButton("Beginner")
beginner_button.setChecked(True)
intermediate_button = QRadioButton("Intermediate")
advanced_button = QRadioButton("Advanced")
self.level_selection.addButton(beginner_button)
self.level_selection.addButton(intermediate_button)
self.level_selection.addButton(advanced_button)
level_layout.addWidget(beginner_button)
level_layout.addWidget(intermediate_button)
level_layout.addWidget(advanced_button)
level_group.setLayout(level_layout)
layout.addWidget(level_group)
self.generate_button = QPushButton("Generate Response")
self.generate_button.clicked.connect(self.generate_response)
layout.addWidget(self.generate_button)
response_group = QGroupBox("Response")
response_layout = QVBoxLayout()
self.response_text = QTextEdit()
self.response_text.setReadOnly(True)
response_layout.addWidget(self.response_text)
self.loading_label = QLabel()
self.loading_label.setMovie(self.loading_movie)
self.loading_label.setAlignment(Qt.AlignCenter)
self.loading_label.hide()
response_layout.addWidget(self.loading_label)
response_group.setLayout(response_layout)
layout.addWidget(response_group)
preferences_group = QGroupBox("Preferences")
preferences_layout = QVBoxLayout()
learning_style_label = QLabel("Learning Style:")
self.learning_style_combo = QComboBox()
self.learning_style_combo.addItems(
["Visual", "Auditory", "Kinesthetic", "Reading/Writing"])
preferences_layout.addWidget(learning_style_label)
preferences_layout.addWidget(self.learning_style_combo)
self.learning_style_combo.currentIndexChanged.connect(self.update_preferences)
topics_label = QLabel("Topics of Interest:")
self.topics_checkboxes = {}
topics = ["Mathematics", "Science", "History", "Programming", "Art"]
for topic in topics:
checkbox = QCheckBox(topic)
self.topics_checkboxes[topic] = checkbox
checkbox.stateChanged.connect(self.update_preferences)
preferences_layout.addWidget(checkbox)
preferences_group.setLayout(preferences_layout)
chat_layout.addWidget(preferences_group)
preferences_button = QPushButton("Preferences")
preferences_button.clicked.connect(self.open_preferences_dialog)
chat_layout.addWidget(preferences_button)
chat_tab.setLayout(chat_layout)
history_layout = QVBoxLayout()
search_group = QGroupBox("Search/Filter")
search_layout = QHBoxLayout()
self.search_text = QLineEdit()
self.search_text.setPlaceholderText("Search...")
self.search_text.textChanged.connect(self.filter_history)
self.date_from = QDateEdit(calendarPopup=True)
self.date_from.setDate(datetime.date(2023, 1, 1))
self.date_to = QDateEdit(calendarPopup=True)
self.date_to.setDate(datetime.date.today())
self.date_from.dateChanged.connect(self.filter_history)
self.date_to.dateChanged.connect(self.filter_history)
search_layout.addWidget(QLabel("Keyword:"))
search_layout.addWidget(self.search_text)
search_layout.addWidget(QLabel("From:"))
search_layout.addWidget(self.date_from)
search_layout.addWidget(QLabel("To:"))
search_layout.addWidget(self.date_to)
search_group.setLayout(search_layout)
history_layout.addWidget(search_group)
self.history_list = QListWidget()
history_layout.addWidget(self.history_list)
history_tab.setLayout(history_layout)
self.history_list.itemDoubleClicked.connect(self.load_history_item)
save_button = QPushButton("Save History")
save_button.clicked.connect(self.save_history)
load_button = QPushButton("Load History")
load_button.clicked.connect(self.load_history_from_file)
history_layout.addWidget(save_button)
history_layout.addWidget(load_button)
export_button = QPushButton("Export History")
export_button.clicked.connect(self.export_history)
history_layout.addWidget(export_button)
history_tab.setLayout(history_layout)
self.loading_label = QLabel()
self.loading_label.setMovie(self.loading_movie)
self.loading_label.setAlignment(Qt.AlignCenter)
self.loading_label.hide()
response_layout.addWidget(self.loading_label)
layout.addWidget(tabs)
self.setLayout(layout)
def generate_response(self):
query = self.query_text.toPlainText()
if not query.strip():
QMessageBox.warning(self, "Error", "Please enter a query.")
return
level = 'beginner' if self.level_selection.buttons()[0].isChecked() else \
'intermediate' if self.level_selection.buttons()[1].isChecked() else "advanced"
preferences = self.assistant.session.preferences
request = UserRequest(query=query, user_level=level, preferences=preferences)
self.generate_button.setEnabled(False)
self.loading_label.show()
self.loading_movie.start()
self.thread = GenerateResponseThread(self.assistant, request)
self.thread.finished.connect(self.display_response)
self.thread.start()
self.response_text.setText("Loading...")
def display_response(self, response):
self.generate_button.setEnabled(True)
self.loading_label.hide()
self.loading_movie.stop()
if "Error generating text response:" in response:
QMessageBox.critical(self, "Error", response.replace("Error generating text response:", ""))
self.response_text.clear()
else:
self.response_text.setText(response)
self.update_history(self.query_text.toPlainText(), response)
self.query_text.clear()
def update_preferences(self):
preferences = {}
preferences["learning_style"] = self.learning_style_combo.currentText()
selected_topics = [topic for topic, checkbox in self.topics_checkboxes.items() if checkbox.isChecked()]
preferences["topics_of_interest"] = selected_topics
self.assistant.session.preferences = preferences
self.save_preferences()
def save_preferences(self):
try:
with open("user_preferences.json", "w") as f:
json.dump(self.assistant.session.preferences, f, indent=4)
except Exception as e:
print(f"Error saving preferences: {e}")
def load_preferences(self):
try:
with open("user_preferences.json", "r") as f:
return json.load(f)
except FileNotFoundError:
return {}
except json.JSONDecodeError as e:
print(f"Error loading preferences: {e}")
return {}
def open_preferences_dialog(self):
dialog = PreferencesDialog(self, self.assistant.session.preferences)
if dialog.exec_() == QDialog.Accepted:
self.assistant.session.preferences = dialog.get_preferences()
self.save_preferences()
def update_history(self, query, response):
timestamp = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
item = QListWidgetItem(f"{timestamp} - {query[:20]}...")
item.setData(Qt.UserRole, {"query": query, "response": response})
self.history_list.addItem(item)
self.history.append({"query": query, "response": response, "timestamp": timestamp})
def load_history_item(self, item):
data = item.data(Qt.UserRole)
self.query_text.setText(data["query"])
self.response_text.setText(data["response"])
def save_history(self):
try:
with open(self.history_file, "w") as f:
json.dump(self.history, f, indent=4)
except Exception as e:
print(f"Error saving history: {e}")
def load_history(self):
try:
with open(self.history_file, "r") as f:
self.history = json.load(f)
self.update_history_list()
except FileNotFoundError:
pass
except json.JSONDecodeError as e:
print(f"Error loading history: Invalid JSON format: {e}")
def load_history_from_file(self):
options = QFileDialog.Options()
filename, _ = QFileDialog.getOpenFileName(self, "Load Chat History", "", "JSON Files (*.json);;All Files (*)",
options=options)
if filename:
try:
with open(filename, "r") as f:
self.history = json.load(f)
self.update_history_list()
except json.JSONDecodeError as e:
print(f"Error loading history from file: Invalid JSON format: {e}")
def filter_history(self):
self.update_history_list(filter_text=self.search_text.text(),
date_from=self.date_from.date().toPyDate(),
date_to=self.date_to.date().toPyDate())
def export_history(self):
options = QFileDialog.Options()
filename, _ = QFileDialog.getSaveFileName(self, "Export Chat History", "",
"Text Files (*.txt);;CSV Files (*.csv)", options=options)
if filename:
try:
with open(filename, "w", encoding="utf-8") as f:
if filename.endswith(".csv"):
f.write("Timestamp,Query,Response\n")
for item in self.history:
timestamp = item.get("timestamp", "")
query = item.get("query", "").replace(",", "\",\"")
response = item.get("response", "").replace(",", "\",\"")
f.write(f'"{timestamp}","{query}","{response}"\n')
else:
for item in self.history:
f.write(
f"{item.get('timestamp', '')}\nQuery: {item.get('query', '')}\nResponse: {item.get('response', '')}\n\n")
QMessageBox.information(self, "Success", "History exported successfully!")
except Exception as e:
QMessageBox.critical(self, "Error", f"Error exporting history: {str(e)}")
def update_history_list(self, filter_text="", date_from=None, date_to=None):
self.history_list.clear()
for item_data in self.history:
timestamp_str = item_data.get("timestamp", "")
query = item_data.get("query", "")
try:
timestamp = datetime.datetime.fromisoformat(timestamp_str).date()
if date_from and timestamp < date_from:
continue
if date_to and timestamp > date_to:
continue
except ValueError:
pass
if filter_text.lower() not in query.lower():
continue
item = QListWidgetItem(f"{timestamp_str} - {query[:20]}...")
item.setData(Qt.UserRole, item_data)
self.history_list.addItem(item)
This class builds the main window, which includes two tabs: "Chat" and "History." The "Chat" tab allows users to enter their queries, select their level, and see the AI's responses. The "History" tab displays past conversations, offering search and export functionalities.
Launching Your AI Study Buddy
Now, let's bring our creation to life:
if __name__ == "__main__":
app = QApplication([])
window = EducationalAssistantGUI()
window.show()
app.exec_()
Congratulations! You've built your own personalized AI learning assistant.
Now that you have a working app, think about how you could make it even better! The BotHub API offers a lot of flexibility. Instead of just text responses, you could integrate image generation or speech transcription. BotHub also gives you access to multiple AI models, allowing you to choose the best one for different tasks. Imagine your assistant being able to summarize complex topics, translate languages, or even generate practice quizzes! The possibilities are vast. You've built a solid foundation; now go forth and explore!
Top comments (0)