DEV Community

Cover image for Tutorial: Create Your Own AI Study Buddy
veseluhha for BotHub

Posted on

Tutorial: Create Your Own AI Study Buddy

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

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

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

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

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

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", [])
       }
Enter fullscreen mode Exit fullscreen mode

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

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

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)