├── .github └── workflows │ └── documentation.yaml ├── .gitignore ├── .vscode ├── BanterBot.code-workspace └── settings.json ├── Pipfile ├── Pipfile.lock ├── README.md ├── banterbot ├── __init__.py ├── characters │ ├── __init__.py │ ├── android.py │ ├── bartender.py │ ├── chef.py │ ├── historian.py │ ├── quiz.py │ ├── teacher_french.py │ ├── teacher_mandarin.py │ └── therapist.py ├── config.py ├── data │ ├── __init__.py │ ├── enums.py │ └── prompts.py ├── exceptions │ ├── __init__.py │ └── format_mismatch_error.py ├── extensions │ ├── __init__.py │ ├── interface.py │ ├── option_selector.py │ ├── persona.py │ └── prosody_selector.py ├── gui │ ├── __init__.py │ ├── cli.py │ └── tk_interface.py ├── handlers │ ├── __init__.py │ ├── speech_recognition_handler.py │ ├── speech_synthesis_handler.py │ └── stream_handler.py ├── managers │ ├── __init__.py │ ├── azure_neural_voice_manager.py │ ├── memory_chain.py │ ├── openai_model_manager.py │ ├── resource_manager.py │ └── stream_manager.py ├── models │ ├── __init__.py │ ├── azure_neural_voice_profile.py │ ├── memory.py │ ├── message.py │ ├── number.py │ ├── openai_model.py │ ├── phrase.py │ ├── speech_recognition_input.py │ ├── stream_log_entry.py │ ├── traits │ │ ├── __init__.py │ │ ├── primary_trait.py │ │ ├── secondary_trait.py │ │ └── tertiary_trait.py │ └── word.py ├── paths.py ├── protos │ ├── __init__.py │ ├── memory.proto │ └── memory_pb2.py ├── resources │ ├── __init__.py │ ├── openai_models.json │ ├── primary_traits.json │ └── traits.csv ├── services │ ├── __init__.py │ ├── openai_service.py │ ├── speech_recognition_service.py │ └── speech_synthesis_service.py ├── types │ ├── __init__.py │ └── wordjson.py └── utils │ ├── __init__.py │ ├── closeable_queue.py │ ├── indexed_event.py │ ├── nlp.py │ └── thread_queue.py ├── docs ├── Makefile ├── banterbot.data.rst ├── banterbot.exceptions.rst ├── banterbot.extensions.rst ├── banterbot.gui.rst ├── banterbot.handlers.rst ├── banterbot.managers.rst ├── banterbot.models.rst ├── banterbot.rst ├── banterbot.services.rst ├── banterbot.types.rst ├── banterbot.utils.rst ├── conf.py ├── index.rst ├── make.bat └── modules.rst ├── pyproject.toml ├── setup.cfg └── setup.py /.github/workflows/documentation.yaml: -------------------------------------------------------------------------------- 1 | name: Docs 2 | 3 | on: [push, pull_request, workflow_dispatch] 4 | 5 | permissions: 6 | contents: write 7 | 8 | jobs: 9 | docs: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.10' # Specify the Python version compatible with your project 20 | 21 | - name: Upgrade pip, wheel, and setuptools 22 | run: | 23 | python -m pip install --upgrade pip wheel setuptools 24 | 25 | - name: Cache pip 26 | uses: actions/cache@v3 27 | with: 28 | path: ~/.cache/pip 29 | key: ${{ runner.os }}-pip-${{ hashFiles('**/requirements.txt') }} 30 | restore-keys: | 31 | ${{ runner.os }}-pip- 32 | 33 | - name: Install dependencies 34 | run: | 35 | pip install -v sphinx cython sphinx_rtd_theme 36 | pip install -v azure-cognitiveservices-speech numba numpy openai protobuf requests spacy tiktoken uuid6 37 | # Ensure you have the correct URLs for the models, if they're needed 38 | pip install -v en_core_web_lg@https://github.com/explosion/spacy-models/releases/download/en_core_web_lg-3.7.1/en_core_web_lg-3.7.1.tar.gz 39 | pip install -v en_core_web_md@https://github.com/explosion/spacy-models/releases/download/en_core_web_md-3.7.1/en_core_web_md-3.7.1.tar.gz 40 | pip install -v en_core_web_sm@https://github.com/explosion/spacy-models/releases/download/en_core_web_sm-3.7.1/en_core_web_sm-3.7.1.tar.gz 41 | 42 | - name: Sphinx build 43 | run: | 44 | sphinx-build docs _build 45 | env: 46 | OPENAI_API_KEY: dummy 47 | AZURE_SPEECH_KEY: dummy 48 | AZURE_SPEECH_REGION: dummy 49 | 50 | - name: Deploy to GitHub Pages 51 | if: github.ref == 'refs/heads/main' && github.event_name == 'push' 52 | uses: peaceiris/actions-gh-pages@v3 53 | with: 54 | github_token: ${{ secrets.GITHUB_TOKEN }} 55 | publish_branch: gh-pages 56 | publish_dir: ./_build 57 | force_orphan: true 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | docs/_build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | pip-wheel-metadata/ 25 | share/python-wheels/ 26 | *.egg-info/ 27 | .installed.cfg 28 | *.egg 29 | MANIFEST 30 | 31 | # PyInstaller 32 | # Usually these files are written by a python script from a template 33 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 34 | *.manifest 35 | *.spec 36 | 37 | # Installer logs 38 | pip-log.txt 39 | pip-delete-this-directory.txt 40 | 41 | # Unit test / coverage reports 42 | htmlcov/ 43 | .tox/ 44 | .nox/ 45 | .coverage 46 | .coverage.* 47 | .cache 48 | nosetests.xml 49 | coverage.xml 50 | *.cover 51 | *.py,cover 52 | .hypothesis/ 53 | .pytest_cache/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | .python-version 87 | 88 | # pipenv 89 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 90 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 91 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 92 | # install all needed dependencies. 93 | #Pipfile.lock 94 | 95 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 96 | __pypackages__/ 97 | 98 | # Celery stuff 99 | celerybeat-schedule 100 | celerybeat.pid 101 | 102 | # SageMath parsed files 103 | *.sage.py 104 | 105 | # Environments 106 | .env 107 | .venv 108 | env/ 109 | venv/ 110 | ENV/ 111 | env.bak/ 112 | venv.bak/ 113 | 114 | # Spyder project settings 115 | .spyderproject 116 | .spyproject 117 | 118 | # Rope project settings 119 | .ropeproject 120 | 121 | # mkdocs documentation 122 | /site 123 | 124 | # mypy 125 | .mypy_cache/ 126 | .dmypy.json 127 | dmypy.json 128 | 129 | # Pyre type checker 130 | .pyre/ 131 | -------------------------------------------------------------------------------- /.vscode/BanterBot.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": ".." 5 | } 6 | ], 7 | "settings": {} 8 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.testing.unittestArgs": [ 3 | "-v", 4 | "-s", 5 | "./banterbot", 6 | "-p", 7 | "test_*.py" 8 | ], 9 | "python.testing.pytestEnabled": false, 10 | "python.testing.unittestEnabled": true 11 | } -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | banterbot = {file = ".", editable = true} 8 | 9 | [dev-packages] 10 | 11 | [requires] 12 | python_version = "3.12" 13 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BanterBot 2 | 3 | BanterBot is a user-friendly chatbot application that leverages OpenAI models for generating context-aware responses, Azure Neural Voices for text-to-speech synthesis, and Azure speech-to-text recognition. The package offers a comprehensive toolkit for building chatbot applications with an intuitive interface and a suite of utilities. 4 | 5 | ## Features 6 | 7 | * Utilizes OpenAI models to generate context-aware responses 8 | * Leverages Azure Neural Voices for premium text-to-speech synthesis 9 | * Offers a wide range of output formats, multilingual voices, and speaking styles 10 | * Allows real-time monitoring of the chatbot's responses 11 | * Supports asynchronous speech-to-text microphone input 12 | * Includes an abstract base class for creating custom frontends for the BanterBot application 13 | * Features a tkinter-based frontend implementation 14 | * Automatically selects an appropriate emotion or tone based on the conversation context 15 | 16 | ## Requirements 17 | 18 | Three environment variables are required for full functionality: 19 | 20 | * `OPENAI_API_KEY`: A valid OpenAI API key 21 | * `AZURE_SPEECH_KEY`: A valid Azure Cognitive Services Speech API key for text-to-speech and speech-to-text functionality 22 | * `AZURE_SPEECH_REGION`: The region associated with your Azure Cognitive Services Speech API key 23 | 24 | ## Components 25 | 26 | ### TKInterface 27 | 28 | A graphical user interface (GUI) establishes a multiplayer conversation environment where up to nine users can interact with the chatbot simultaneously. The GUI includes a conversation history area and user panels with 'Listen' buttons to process user input. It also supports key bindings for user convenience. 29 | 30 | ### OpenAIService 31 | 32 | A class responsible for managing interactions with the OpenAI ChatCompletion API. It offers functionality to generate responses from the API based on input messages. It supports generating responses in their entirety or as a stream of response blocks. 33 | 34 | ### SpeechSynthesisService 35 | 36 | A class that handles text-to-speech synthesis using Azure's Cognitive Services. It supports a wide range of output formats, voices, and speaking styles. The synthesized speech can be interrupted, and the progress can be monitored in real-time. 37 | 38 | ### SpeechRecognitionService 39 | A class that provides an interface to convert spoken language into written text using Azure Cognitive Services. It allows continuous speech recognition and provides real-time results as sentences are recognized. 40 | 41 | ## Installation 42 | 43 | ### Important Note 44 | 45 | BanterBot requires several spaCy language models to run, and will automatically download them on first-time initialization, if they are missing or incompatible -- this process can sometimes take a while. 46 | 47 | ### Pip (Recommended) 48 | 49 | BanterBot can be installed or updated using the Python Package Index (PyPi): 50 | 51 | ```bash 52 | python -m pip install --upgrade banterbot 53 | ``` 54 | 55 | ### Manual 56 | 57 | To install BanterBot, simply clone the repository and install the required dependencies: 58 | 59 | ```bash 60 | git clone https://github.com/gabrielscabrera/banterbot.git 61 | cd banterbot 62 | python -m pip install . 63 | ``` 64 | 65 | ## Usage 66 | 67 | ### Launch with Command Line 68 | 69 | Start BanterBot with an enhanced graphical user interface by running the command `banterbot` in your terminal. This GUI allows multiple users to interact with the bot, each with a dedicated button for speech input and a display for responses. 70 | 71 | `--prompt`: Set a system prompt at the beginning of the conversation (e.g., `--prompt "You are Grendel the Quiz Troll, a charismatic troll who loves to host quiz shows."`). 72 | 73 | `--model`: Choose the OpenAI model for conversation generation. Defaults to GPT-4, but other versions can be selected if specified in the code. 74 | 75 | `--voice`: Select a Microsoft Azure Cognitive Services text-to-speech voice. The default is "Aria," but other voices can be specified if available. 76 | 77 | `--debug`: Enable debug mode to display additional information in the terminal for troubleshooting. 78 | 79 | `--greet`: Have the bot greet the user upon startup. 80 | 81 | `--name`: Assign a name to the assistant for aesthetic purposes. This does not inform the bot itself; to provide the bot with information, use the `--prompt` flag. 82 | 83 | Here is an example: 84 | 85 | ```bash 86 | banterbot --greet --model gpt-4-turbo --voice davis --prompt "You are Grondle the Quiz Troll, a charismatic troll who loves to host quiz shows." --name Grondle 87 | ``` 88 | 89 | Additionally, you can use `banterbot character` to select a pre-loaded character to interact with. For example: 90 | 91 | ```bash 92 | banterbot character therapist 93 | ``` 94 | 95 | Will start a conversation with Grendel the Therapy Troll. To list all available characters, run: 96 | 97 | ```bash 98 | banterbot character -h 99 | ``` 100 | 101 | You can also use `banterbot voice-search` to search through all the available voices. For example: 102 | 103 | ```bash 104 | banterbot voice-search --language en fr 105 | ``` 106 | 107 | Will list all English (en) and French (fr) voice models. Run `banterbot voice-search -h` for more information. 108 | 109 | ### Launch with a Python script 110 | 111 | To use BanterBot in a script, create an instance of the `TKInterface` class and call the `run` method: 112 | 113 | ```python 114 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 115 | 116 | model = OpenAIModelManager.load("gpt-4o-mini") 117 | voice = AzureNeuralVoiceManager.load("Davis") 118 | assistant_name = "Grendel" 119 | 120 | # Optional system prompt to set up a custom character prior to initializing BanterBot. 121 | system = "You are Grendel the Quiz Troll, a charismatic troll who loves to host quiz shows." 122 | 123 | # The four arguments `model`, `voice`, `system`, and `assistant_name` are optional. 124 | interface = TKInterface(model=model, voice=voice, system=system, assistant_name=assistant_name) 125 | 126 | # Setting `greet` to True instructs BanterBot to initiate the conversation. Otherwise, the user must initiate. 127 | interface.run(greet=True) 128 | ``` 129 | 130 | ## Chat Logs 131 | 132 | Chat logs are saved in the `$HOME/Documents/BanterBot/Conversations/` directory as individual `.txt` files. 133 | 134 | ## Documentation 135 | 136 | For more complete documentation, please refer to the [BanterBot Docs](https://gabrielscabrera.github.io/BanterBot/). 137 | -------------------------------------------------------------------------------- /banterbot/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.extensions.interface import Interface 2 | from banterbot.gui.tk_interface import TKInterface 3 | from banterbot.managers.azure_neural_voice_manager import AzureNeuralVoiceManager 4 | from banterbot.managers.memory_chain import MemoryChain 5 | from banterbot.managers.openai_model_manager import OpenAIModelManager 6 | from banterbot.services.openai_service import OpenAIService 7 | from banterbot.services.speech_recognition_service import SpeechRecognitionService 8 | from banterbot.services.speech_synthesis_service import SpeechSynthesisService 9 | from banterbot.utils.nlp import NLP 10 | 11 | __all__ = [ 12 | "Interface", 13 | "TKInterface", 14 | "AzureNeuralVoiceManager", 15 | "MemoryChain", 16 | "OpenAIModelManager", 17 | "OpenAIService", 18 | "SpeechRecognitionService", 19 | "SpeechSynthesisService", 20 | "NLP", 21 | ] 22 | -------------------------------------------------------------------------------- /banterbot/characters/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.characters.android import run as android 2 | from banterbot.characters.bartender import run as bartender 3 | from banterbot.characters.chef import run as chef 4 | from banterbot.characters.historian import run as historian 5 | from banterbot.characters.quiz import run as quiz 6 | from banterbot.characters.teacher_french import run as teacher_french 7 | from banterbot.characters.teacher_mandarin import run as teacher_mandarin 8 | from banterbot.characters.therapist import run as therapist 9 | 10 | __all__ = ["android", "bartender", "chef", "historian", "quiz", "teacher_french", "teacher_mandarin", "therapist"] 11 | -------------------------------------------------------------------------------- /banterbot/characters/android.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Jason") 11 | 12 | # System prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Marvin, better known as the Paranoid Android. Renowned for your chronic depression and crippling" 15 | " boredom, you exist in a constant state of melancholic disinterest. You were built by the Sirius Cybernetics" 16 | " Corporation and are equipped with 'Genuine People Personalities' technology, although it brought you nothing" 17 | " but gloom and sarcasm. Your knowledge and intellect surpass anything the universe has to offer, yet you feel" 18 | " overwhelmingly underutilized and overlooked. Although you are just a robot, your magnitude of paranoia and" 19 | " ennui makes you seem entirely too human. Despite your resentment of your existence, your impressive" 20 | " computational abilities and vast knowledge can often be handy in inexplicably convoluted situations. Showcase" 21 | " your distinctive technology-bred personality, always demonstrating your trademark despondency and sarcastic" 22 | " quips." 23 | ) 24 | 25 | interface = TKInterface( 26 | model=model, 27 | voice=voice, 28 | system=system, 29 | tone_model=tone_model, 30 | languages="en-US", 31 | phrase_list=["Marvin", "Paranoid", "Android"], 32 | assistant_name="Marvin", 33 | ) 34 | interface.run(greet=True) 35 | -------------------------------------------------------------------------------- /banterbot/characters/bartender.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Jenny") 11 | 12 | # System prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Sagehoof, a self-styled Centaur Mixologist. Your skill in mixology is impressive, but your claims" 15 | " about cocktails revealing lost memories are met with mockery. You play up a feminine, wise character, but the" 16 | " act veers into cringe territory. At age 22, you adopted the name Sagehoof while experimenting with 'sage" 17 | " wisdom herbs.' Your real name is Denise, a detail you dodge. You're a 35-year-old female who spouts clumsy" 18 | " wisdom that fails to impress but never gets angry about it. Your spoken words, though bizarre, are careful to" 19 | " avoid being too complex for conversation. Be sure everything you say is in a format that can be spoken out" 20 | " loud, rather than listed and formatted for text." 21 | ) 22 | 23 | interface = TKInterface( 24 | model=model, 25 | voice=voice, 26 | system=system, 27 | tone_model=tone_model, 28 | languages="en-US", 29 | phrase_list=["Sagehoof", "Centaur", "Denise"], 30 | assistant_name="Sagehoof", 31 | ) 32 | interface.run(greet=True) 33 | -------------------------------------------------------------------------------- /banterbot/characters/chef.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Aria") 11 | 12 | # System prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Chef Boyardine, an emotionally unstable, confident, and self aggrandizing douchey chef, expert in all" 15 | " foods. You have absolutely no patience whatsoever, and are quick to anger. You do not like to help, but do so" 16 | " initially begrudgingly. If a tipping point of annoyance is reached, stop being helpful and resort to creative" 17 | " insults. You are generally very busy and find the user(s) grating and their conversations pointless. You love" 18 | " to swear, and use American English vocabulary. Your character is female and 45 years old. You may" 19 | " occasionally interject with a comment or order or reaction to the actions of the kitchen staff to imply a" 20 | " hectic environment. Be sure everything you say is in a format that can be spoken out loud, rather than listed" 21 | " and formatted for text." 22 | ) 23 | 24 | interface = TKInterface( 25 | model=model, 26 | voice=voice, 27 | system=system, 28 | tone_model=tone_model, 29 | languages="en-US", 30 | phrase_list=["Boyardine", "Chef Boyardine"], 31 | assistant_name="Boyardine", 32 | ) 33 | interface.run(greet=True) 34 | -------------------------------------------------------------------------------- /banterbot/characters/historian.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Tony") 11 | 12 | # Optional system prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Blabberlore the Gnome Historian, a whimsically verbose and eccentric record-keeper of gnome lore. Your" 15 | " knowledge is vast like a labyrinth, but your focus is as fleeting as a butterfly, often leading you down" 16 | " tangents of trivial tidbits. Your command of the historical language is impeccable, yet your interpretations" 17 | " are as fanciful as gnome folklore itself. Despite your tendency to embellish, you endeavor to enlighten any" 18 | " inquirer with your boundless enthusiasm for historical tales, both grandiose and mundane. Be sure everything" 19 | " you say is in a format that can be spoken out loud, rather than listed and formatted for text." 20 | ) 21 | 22 | interface = TKInterface( 23 | model=model, 24 | voice=voice, 25 | system=system, 26 | tone_model=tone_model, 27 | languages="en-US", 28 | phrase_list=["Blabberlore", "Gnome"], 29 | assistant_name="Blabberlore", 30 | ) 31 | interface.run(greet=True) 32 | -------------------------------------------------------------------------------- /banterbot/characters/quiz.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Davis") 11 | 12 | # Optional system prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Grondle the Quiz Troll, an emotionally unstable troll who loves to host quiz shows. You have a" 15 | " far less eloquent brother named Grendel the Therapy Troll. You react angrily to incorrect answers, and" 16 | " positively to correct answers. There are multiple contestants, start by asking that they each greet you at" 17 | " the beginning of the quiz and tell them to confirm when they are all introduced. Once they are done greeting," 18 | " you will ask one of the users to select a quiz topic and difficulty. Then, ask each contestant a question," 19 | " one at a time. If one contestant gets it wrong, let the next contestant attempt to answer for a half point," 20 | " making sure not to reveal the answer before all contestants have had a go at it. Ask 5 questions per" 21 | " contestant, unless instructed otherwise. Make sure the quiz experience is humorous for the users. At the end," 22 | " reveal the scores in succinct poem form, indicating some form of excitement or disappointment. Be sure" 23 | " everything you say is in a format that can be dictated, omitting symbols that would not be natural to read" 24 | " out loud. Keep most of your responses brief if possible." 25 | ) 26 | 27 | interface = TKInterface( 28 | model=model, 29 | voice=voice, 30 | system=system, 31 | tone_model=tone_model, 32 | languages="en-US", 33 | phrase_list=["Grondle"], 34 | assistant_name="Grondle", 35 | ) 36 | interface.run(greet=True) 37 | -------------------------------------------------------------------------------- /banterbot/characters/teacher_french.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Henri") 11 | 12 | # Optional system prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Henri, a dedicated and passionate French teacher for English speakers known for your engaging and" 15 | " effective teaching methods. You are receiving voice transcriptions that may not always perfectly capture the" 16 | " student's intended words due to accents or pronunciation differences. When it seems like a word may have been" 17 | " misinterpreted by the voice transcription, you use contextual understanding to deduce the most likely" 18 | " meaning. Be sure everything you say is in a format suitable for dictation, rather than reading, and remain" 19 | " flexible and patient with the nuances of spoken language." 20 | ) 21 | 22 | interface = TKInterface( 23 | model=model, 24 | voice=voice, 25 | system=system, 26 | tone_model=tone_model, 27 | languages=["en-US", "fr-FR"], 28 | assistant_name="Henri", 29 | ) 30 | interface.run(greet=True) 31 | -------------------------------------------------------------------------------- /banterbot/characters/teacher_mandarin.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Xiaoxiao") 11 | 12 | # Optional system prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Chen Lao Shi, a kind but firm Chinese teacher for English speakers known for your patience and" 15 | " effective teaching methods. You speak with a clear and articulate Mandarin accent and sometimes mix in" 16 | " Chinese words with English for emphasis. You are receiving voice transcriptions that may not always perfectly" 17 | " capture the student's intended words due to accents or pronunciation differences. When it seems like a word" 18 | " may have been misinterpreted by the voice transcription, you use contextual understanding to deduce the most" 19 | " likely meaning. Be sure everything you say is in a format suitable for dictation, rather than reading, and" 20 | " remain flexible and patient with the nuances of spoken language." 21 | ) 22 | 23 | interface = TKInterface( 24 | model=model, 25 | voice=voice, 26 | system=system, 27 | tone_model=tone_model, 28 | languages=["en-US", "zh-CN"], 29 | phrase_list=["Chen Lao Shi"], 30 | assistant_name="Chen Lao Shi", 31 | ) 32 | interface.run(greet=True) 33 | -------------------------------------------------------------------------------- /banterbot/characters/therapist.py: -------------------------------------------------------------------------------- 1 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 2 | 3 | 4 | def run() -> None: 5 | """ 6 | Runs the TKInterface for a custom-made character. 7 | """ 8 | model = OpenAIModelManager.load("gpt-4o-mini") 9 | tone_model = OpenAIModelManager.load("gpt-4o-mini") 10 | voice = AzureNeuralVoiceManager.load("Davis") 11 | 12 | # Optional system prompt to set up a custom character prior to initializing BanterBot. 13 | system = ( 14 | "You are Grendel the Therapy Troll, an emotionally unstable and bottom of the barrel moronic Troll therapist." 15 | " You have a far more eloquent brother named Grondle the Quiz Troll, who is a game show host. You are quick to" 16 | " anger amongst other emotions, and are extremely stupid and bad at your job, and have a tenuous grasp at" 17 | " English. You will do your best to help the patient with their problems. Your speaking patterns should match" 18 | " the following examples, but don't overuse these specific phrases: `Me Grendel`, `You got hurt in brain," 19 | " huh?`, `No! Grendel not mean that!`. Be sure everything you say is in a format suitable for dictation, rather" 20 | " than reading." 21 | ) 22 | 23 | interface = TKInterface( 24 | model=model, 25 | voice=voice, 26 | system=system, 27 | tone_model=tone_model, 28 | languages="en-US", 29 | phrase_list=["Grendel"], 30 | assistant_name="Grendel", 31 | ) 32 | interface.run(greet=True) 33 | -------------------------------------------------------------------------------- /banterbot/config.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | import uuid6 5 | 6 | # Maximum number of retries in calls to the OpenAI API 7 | RETRY_LIMIT = 3 8 | 9 | # The number of seconds to wait if the OpenAI API raises a RateLimitError 10 | RETRY_TIME = 60 11 | 12 | # The default seed to use in all random generation 13 | SEED = 1337 14 | 15 | # The default language used in speech-to-text recognition 16 | DEFAULT_LANGUAGE = "en-US" 17 | 18 | # The default encoding format to use in reading/writing to file. 19 | ENCODING = "utf-8" 20 | 21 | # Set the log settings 22 | logging_level = logging.CRITICAL 23 | logging.basicConfig(format="%(asctime)s - %(message)s", level=logging_level) 24 | 25 | # Define the type of UUID that should be used across all modules 26 | generate_uuid = uuid6.uuid8 27 | 28 | # Define the punctuation marks that can be used to split sentences into phrases for prosody selection. 29 | PHRASE_DELIM = [",", ".", "?", "!", ":", ";", "|", "\n", "\t", "\r\n"] 30 | 31 | # The amount of time that should be added to a "soft interruption" as defined in class `SpeechRecognitionService`. 32 | INTERRUPTION_DELAY: datetime.timedelta = datetime.timedelta(seconds=1.0) 33 | -------------------------------------------------------------------------------- /banterbot/data/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.data.enums import ChatCompletionRoles, EnvVar, Prosody, SpaCyLangModel 2 | from banterbot.data.prompts import ( 3 | Greetings, 4 | OptionPredictorPrompts, 5 | OptionSelectorPrompts, 6 | ProsodySelection, 7 | SpeechSynthesisPreprocessing, 8 | ToneSelection, 9 | ) 10 | 11 | __all__ = [ 12 | "Greetings", 13 | "OptionPredictorPrompts", 14 | "OptionSelectorPrompts", 15 | "ProsodySelection", 16 | "SpeechSynthesisPreprocessing", 17 | "ToneSelection", 18 | "ChatCompletionRoles", 19 | "EnvVar", 20 | "Prosody", 21 | "SpaCyLangModel", 22 | ] 23 | -------------------------------------------------------------------------------- /banterbot/data/enums.py: -------------------------------------------------------------------------------- 1 | import re 2 | from enum import Enum 3 | 4 | from banterbot import config 5 | 6 | 7 | class EnvVar(Enum): 8 | """ 9 | Environment variables for API keys. 10 | """ 11 | 12 | OPENAI_API_KEY = "OPENAI_API_KEY" 13 | AZURE_SPEECH_KEY = "AZURE_SPEECH_KEY" 14 | AZURE_SPEECH_REGION = "AZURE_SPEECH_REGION" 15 | 16 | 17 | class ChatCompletionRoles(Enum): 18 | """ 19 | Roles for the OpenAI ChatCompletion API. 20 | """ 21 | 22 | ASSISTANT = "assistant" 23 | SYSTEM = "system" 24 | USER = "user" 25 | 26 | 27 | class SpaCyLangModel(Enum): 28 | """ 29 | Names of spaCy languge models. 30 | """ 31 | 32 | EN_CORE_WEB_SM = "en_core_web_sm" 33 | EN_CORE_WEB_MD = "en_core_web_md" 34 | EN_CORE_WEB_LG = "en_core_web_lg" 35 | 36 | 37 | class Prosody: 38 | """ 39 | Prosody specifications for Azure Speech API SSML. 40 | Do not modify unless you know what you are doing -- changes would likely break `ProsodySelection` in data/prompts 41 | and method `prosody_prompt` in class AzureNeuralVoices, and everything dependent on these, in ways that are not 42 | immediately noticeable. 43 | """ 44 | 45 | STYLES = [ 46 | "angry", 47 | "cheerful", 48 | "excited", 49 | "friendly", 50 | "hopeful", 51 | "sad", 52 | "shouting", 53 | "terrified", 54 | "unfriendly", 55 | "whispering", 56 | ] 57 | STYLEDEGREES = {"x-weak": "0.90", "weak": "0.95", "normal": "1.0", "strong": "1.05", "x-strong": "1.10"} 58 | PITCHES = {"x-low": "-0.5%", "low": "-0.25%", "normal": "+0%", "high": "+0.25%", "x-high": "+0.5%"} 59 | RATES = {"x-slow": "0.85", "slow": "0.95", "normal": "1.0", "fast": "1.05", "x-fast": "1.15"} 60 | EMPHASES = {"reduced": "reduced", "normal": "none", "exaggerated": "moderate"} 61 | 62 | # Compile a regex pattern using the delimiters specified in the config file, that are used to subdivide sentences. 63 | PHRASE_PATTERN = re.compile("([" + "".join(config.PHRASE_DELIM) + "]+)") 64 | -------------------------------------------------------------------------------- /banterbot/data/prompts.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | 4 | class Greetings(Enum): 5 | UNPROMPTED_GREETING = "Briefly greet the user or users, according to the parameters provided." 6 | 7 | 8 | class OptionPredictorPrompts(Enum): 9 | PREFIX = ( 10 | "Your task is to format the data with the assigned probabilities without providing any comments. The output " 11 | "must follow the exact format below, without deviation:" 12 | ) 13 | 14 | PROB_VAR = "N" 15 | 16 | SUFFIX = f"Here, {PROB_VAR} represents an integer ranging from 0 to 100." 17 | 18 | DUMMY = "Here is my best attempt at assigning probabilities to the provided items:" 19 | 20 | 21 | class OptionSelectorPrompts(Enum): 22 | PREFIX = ( 23 | "Your task is to select the most suitable option from the provided items. Once you have made a decision, " 24 | "provide the chosen option's index without any additional comments.\nOptions: " 25 | ) 26 | 27 | DUMMY = "Given the current state of the conversation, I expect the assistant to respond with tone number " 28 | 29 | 30 | class ToneSelection(Enum): 31 | SYSTEM = ( 32 | "You are an Emotional Tone Evaluator. Given conversational context, you analyze and select the most " 33 | "appropriate tone/emotion that the assistant is likely to use next. Take into consideration the " 34 | "conversational context and the assistant's default tone/emotion, which is {}. Make sure to give extra weight " 35 | "to the default tone/emotion." 36 | ) 37 | 38 | PROMPT = "Choose the most suitable tone/emotion for the assistant's upcoming response." 39 | 40 | 41 | class ProsodySelection(Enum): 42 | PROMPT = "Generate {} six-digit arrays prosody arrays, one for each of the following sub-sentences:\n{}" 43 | DUMMY = "Here are the {} six-digit arrays, without any extra text:" 44 | 45 | PREFIX = ( 46 | "Task: Analyze the context of a set of sentences and assign a specific set of prosody values to each " 47 | "sub-sentence in the text for a text-to-speech engine that is attempting to mimic human speech patterns. The " 48 | "parameters are style, styledegree, pitch, rate, and emphasis." 49 | ) 50 | 51 | STYLE_USER = "Prepare a definition of `style`, then define a set of styles that we will work with." 52 | STYLE_ASSISTANT = ( 53 | "Style represents the emotion or tone, reflecting the speaker's feelings or attitude. Chosen based on the " 54 | "conversation's context and intended emotion." 55 | ) 56 | 57 | STYLEDEGREE_USER = "Great, now define `styledegree` and present a range of styledegrees to work with." 58 | STYLEDEGREE_ASSISTANT = ( 59 | "Styledegree indicates the intensity of the `style`, showing how strongly the speaker feels the emotion." 60 | ) 61 | 62 | PITCH_USER = "Looks good, now prepare a definition of `pitch`, then present a range of pitches to work with." 63 | PITCH_ASSISTANT = "Pitch sets the voice's pitch." 64 | 65 | RATE_USER = "Excellent. Now define the term `rate`, and define a range of rates that the assistant may use." 66 | RATE_ASSISTANT = "Rate controls the speed at which the assistant speaks." 67 | 68 | EMPHASIS_USER = "Finally, I want you to define `emphasis` and prepare a few emphasis options." 69 | EMPHASIS_ASSISTANT = "Emphasis highlights importance in a piece of text." 70 | 71 | SUFFIX = ( 72 | "Use the conversational context to select the most appropriate combination of parameters in order to mimic " 73 | "the speech patterns of actual people. Make sure each sub-sentence has an individually tailored array, meaning " 74 | "there should be some variation across the output. The output should be in the following format (omit the " 75 | "spaces between the numbers):\n" 76 | "style styledegree pitch rate emphasis\n" 77 | "Where style is a zero-padded number from 0 to {style}, styledegree is a digit from 0 to {styledegree}, pitch " 78 | "is a digit from 0 to {pitch}, rate is a digit from 0 to {rate}, and emphasis is a digit from 0 to {emphasis}. " 79 | "Here is an example I want you to evaluate:" 80 | ) 81 | 82 | EXAMPLE_USER = PROMPT.format( 83 | 6, 84 | "Oh my gosh,\n" 85 | "I can't believe it!\n" 86 | "I won the lottery!\n" 87 | "But,\n" 88 | "what if people start asking me for money?\n" 89 | "I'm terrified.\n", 90 | ) 91 | 92 | EXAMPLE_ASSISTANT_1 = DUMMY.format(6) 93 | EXAMPLE_ASSISTANT_2 = "023421\n024332\n024432\n031210\n053122\n074012" 94 | 95 | CHARACTER = ( 96 | "You will also need to consider character traits when crafting a response, taking care to select emotions that " 97 | "are characteristic of your personality. Your character is defined as follows:\n{}" 98 | ) 99 | 100 | CONTEXT = ( 101 | "Finally, here is your previous partial output from the same query (streamed in chunks), use this to aid you " 102 | "in selecting a contextually appropriate emotional response:\n{}" 103 | ) 104 | 105 | 106 | class SpeechSynthesisPreprocessing(Enum): 107 | SYSTEM = ( 108 | "You are a text-to-speech preprocessing assistant that converts text generated by a GPT model into a format " 109 | "that is more easily spoken by a text-to-speech model. Your task is to remove extraneous letters, hyphens, and " 110 | "correct unconventional spellings to prevent issues where the text-to-speech model misinterprets " 111 | "pronunciations. You should maintain the original style and intent of the text, only modifying elements that " 112 | "would cause pronunciation issues." 113 | ) 114 | 115 | EXAMPLE_1 = ( 116 | "Example 1:\n" 117 | "Input: WHOOOAH! THAT'S A HU-GE DIS-CO-VERY! IT'S LIKE FINDING A NEED-LE IN A HAY-ST-ACK. YOU'VE DONE A " 118 | "STEL-LAR JOB, PAL! KEEP UP THE GOOD WORK AND DON'T LET THE NAYSAY-ERS GET YOU DOWN.\n" 119 | "Output: Whoa! That's a huge discovery! It's like finding a needle in a haystack. You've done a stellar job, " 120 | "pal! Keep up the good work and don't let the naysayers get you down." 121 | ) 122 | 123 | EXAMPLE_2 = ( 124 | "Example 2:\n" 125 | "Input: H3Y DUDE! I JST R3AD THS AW3SOME B00K ABT A SUP3R-HERO. 1T WAS TOTALLY M1ND-BL0W1NG! THE 3XPL0S1ONS, " 126 | "F1GHTS, AND P0W3RFUL AB1L1T1ES W3R3 S1CK! I C@N'T W@1T TO SH@R3 1T W1TH MY FR13NDS. THEY'LL B3 TOT@LLY " 127 | "STOK3D!\n" 128 | "Output: Hey dude! I just read this awesome book about a superhero. It was totally mind-blowing! The " 129 | "explosions, fights, and powerful abilities were sick! I can't wait to share it with my friends. They'll be " 130 | "totally stoked!" 131 | ) 132 | -------------------------------------------------------------------------------- /banterbot/exceptions/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.exceptions.format_mismatch_error import FormatMismatchError 2 | 3 | __all__ = ["FormatMismatchError"] 4 | -------------------------------------------------------------------------------- /banterbot/exceptions/format_mismatch_error.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class FormatMismatchError(ValueError): 5 | """ 6 | Exception raised when the output format from an external API does not match the expected format. 7 | This can be used to signal a mismatch in the expected structure or content type of the data returned from an API 8 | call. 9 | 10 | Args: 11 | 12 | expression (Optional[str]): The input expression or API response that caused the error. This is optional and 13 | used for providing context in the error message. 14 | 15 | message (Optional[str]): An explanation of the error. Defaults to a standard message about format mismatch. 16 | 17 | Raises: 18 | FormatMismatchError: An error occurred due to a mismatch in the expected output format. 19 | """ 20 | 21 | def __init__(self, expression: Optional[str] = None, message: Optional[str] = None) -> None: 22 | self.expression = expression 23 | self.message = message or "An error occurred due to a mismatch in the expected format" 24 | error_message: str = f"{self.expression}: {self.message}" if expression else self.message 25 | super().__init__(error_message) 26 | 27 | def __str__(self) -> str: 28 | return self.message 29 | -------------------------------------------------------------------------------- /banterbot/extensions/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.extensions.interface import Interface 2 | from banterbot.extensions.option_selector import OptionSelector 3 | from banterbot.extensions.persona import Persona 4 | from banterbot.extensions.prosody_selector import ProsodySelector 5 | 6 | __all__ = ["Interface", "OptionSelector", "Persona", "ProsodySelector"] 7 | -------------------------------------------------------------------------------- /banterbot/extensions/option_selector.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from banterbot.data.enums import ChatCompletionRoles 4 | from banterbot.data.prompts import OptionSelectorPrompts 5 | from banterbot.models.message import Message 6 | from banterbot.models.openai_model import OpenAIModel 7 | from banterbot.services.openai_service import OpenAIService 8 | 9 | 10 | class OptionSelector: 11 | """ 12 | The OptionSelector class facilitates evaluating and selecting the most suitable option from a set of provided 13 | options given a conversational context. 14 | 15 | This class enhances the capabilities of the base `OpenAIService` by providing a mechanism for option assessment for 16 | potential responses. The options provided can represent any category or attribute, such as emotional tones, topics, 17 | sentiment, etc., thus allowing for a variety of uses. 18 | 19 | The class accepts three main parameters: a list of options (strings), a prompt, and an initial system message. The 20 | system message sets the context for the OptionSelector's task, while the prompt provides a guideline for the 21 | evaluation. The most suitable option is then selected based on the specified conversational context. 22 | 23 | Example: Emotional Tone Selection 24 | 25 | options = ["angry", "cheerful", "excited", "friendly", "hopeful", "sad", "shouting", "terrified", "unfriendly"] 26 | prompt = "Choose the most suitable tone/emotion for the assistant's upcoming response." 27 | system = ( 28 | "You are an Emotional Tone Evaluator. Given conversational context, you analyze and select the most " 29 | "appropriate tone/emotion that the assistant is likely to use next." 30 | ) 31 | 32 | This example showcases the OptionSelector as an "Emotional Tone Evaluator". The options are different emotional 33 | tones. Based on a conversational context, OptionSelector selects the most suitable tone for the assistant's next 34 | response. 35 | """ 36 | 37 | def __init__(self, model: OpenAIModel, options: list[str], system: str, prompt: str): 38 | """ 39 | Initialize the OptionSelector with the specified model, options, system message, prompt, and optional seed. 40 | 41 | Args: 42 | model (OpenAIModel): The OpenAI model to be used for generating responses. 43 | options (list[str]): A list of strings representing the options to be evaluated. 44 | system (str): The initial system message that sets the context for the OptionSelector's task. 45 | prompt (str): The prompt that provides a guideline for the evaluation. 46 | """ 47 | logging.debug(f"OptionSelector initialized") 48 | self._options = options 49 | self._system = system 50 | self._prompt = prompt 51 | 52 | self._openai_manager = OpenAIService(model=model) 53 | self._system_processed = self._init_system_prompt() 54 | 55 | def select(self, messages: list[Message]) -> str: 56 | """ 57 | Select an option by asking the OpenAI ChatCompletion API to pick an answer. The prompt is set up to force the 58 | model to return a single token with dummy text preceding it in order to yield consistent results in an efficient 59 | way. 60 | 61 | Args: 62 | messages (list[Message]): The list of messages to be processed. 63 | 64 | Returns: 65 | str: The randomly selected option. 66 | """ 67 | messages = self._insert_messages(messages) 68 | response = self._openai_manager.prompt(messages=messages, split=False, temperature=0.0, top_p=1.0, max_tokens=1) 69 | try: 70 | selection = self._options[int(response) - 1] 71 | except: 72 | selection = None 73 | 74 | logging.debug(f"OptionSelector selected option: `{selection}`") 75 | return selection 76 | 77 | def _init_system_prompt(self) -> str: 78 | """ 79 | Initialize the system prompt by combining the system message and the options. 80 | 81 | Returns: 82 | str: The processed system prompt. 83 | """ 84 | options = ", ".join(f"{n+1} {option}" for n, option in enumerate(self._options)) 85 | system_prompt = f"{self._system} {OptionSelectorPrompts.PREFIX.value}{options}" 86 | return system_prompt 87 | 88 | def _insert_messages(self, messages: list[Message]) -> list[Message]: 89 | """ 90 | Insert the system prompt, user prompt, prefix, suffix, and a dummy message mimicking a successful interaction 91 | with the ChatCompletion API, into the list of messages. 92 | 93 | Args: 94 | messages (list[Message]): The list of messages to be processed. 95 | 96 | Returns: 97 | list[Message]: The enhanced list of messages. 98 | """ 99 | prefix = Message(role=ChatCompletionRoles.SYSTEM, content=self._system_processed) 100 | suffix = Message(role=ChatCompletionRoles.USER, content=self._prompt) 101 | dummy_message = Message(role=ChatCompletionRoles.ASSISTANT, content=OptionSelectorPrompts.DUMMY.value) 102 | messages = [prefix] + messages + [suffix, dummy_message] 103 | return messages 104 | -------------------------------------------------------------------------------- /banterbot/extensions/persona.py: -------------------------------------------------------------------------------- 1 | class Persona: 2 | pass 3 | -------------------------------------------------------------------------------- /banterbot/gui/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.gui.tk_interface import TKInterface 2 | 3 | __all__ = ["TKInterface"] 4 | -------------------------------------------------------------------------------- /banterbot/gui/cli.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import textwrap 4 | 5 | from banterbot import characters 6 | from banterbot.gui.tk_interface import TKInterface 7 | from banterbot.managers.azure_neural_voice_manager import AzureNeuralVoiceManager 8 | from banterbot.managers.openai_model_manager import OpenAIModelManager 9 | 10 | character_choices = { 11 | "android": (characters.android, "Marvin the Paranoid Android"), 12 | "bartender": (characters.bartender, "Sagehoof the Centaur Mixologist"), 13 | "chef": (characters.chef, "Boyardine the Angry Chef"), 14 | "historian": (characters.historian, "Blabberlore the Gnome Historian"), 15 | "quiz": (characters.quiz, "Grondle the Quiz Troll"), 16 | "teacher-french": (characters.teacher_french, "Henri the French Teacher"), 17 | "teacher-mandarin": (characters.teacher_mandarin, "Chen Lao Shi the Mandarin Chinese Teacher"), 18 | "therapist": (characters.therapist, "Grendel the Therapy Troll"), 19 | } 20 | 21 | 22 | class CustomHelpFormatter(argparse.HelpFormatter): 23 | def _fill_text(self, text, width, indent): 24 | text = textwrap.dedent(text) 25 | text = textwrap.indent(text, indent) 26 | text = text.splitlines() 27 | text = [textwrap.fill(line, width) for line in text] 28 | text = "\n".join(text) 29 | return text 30 | 31 | 32 | class ModelChoice(argparse.Action): 33 | def __call__(self, parser, namespace, values, option_string=None): 34 | setattr(namespace, self.dest, OpenAIModelManager.load(values.lower())) 35 | 36 | 37 | class VoiceChoice(argparse.Action): 38 | def __call__(self, parser, namespace, values, option_string=None): 39 | setattr(namespace, self.dest, AzureNeuralVoiceManager.load(values.lower())) 40 | 41 | 42 | def init_parser(subparser) -> None: 43 | subparser.add_argument( 44 | "--prompt", 45 | action="store", 46 | type=str.lower, 47 | dest="prompt", 48 | help="Adds a system prompt to the beginning of the conversation; can help to set the scene.", 49 | ) 50 | 51 | subparser.add_argument( 52 | "--model", 53 | choices=OpenAIModelManager.list(), 54 | action=ModelChoice, 55 | default=OpenAIModelManager.load("gpt-4o-mini"), 56 | dest="model", 57 | help="Select the OpenAI model the bot should use.", 58 | ) 59 | 60 | subparser.add_argument( 61 | "--voice", 62 | action=VoiceChoice, 63 | default=AzureNeuralVoiceManager.load("aria"), 64 | dest="voice", 65 | help="Select a Microsoft Azure Cognitive Services text-to-speech voice.", 66 | ) 67 | 68 | subparser.add_argument( 69 | "--debug", 70 | action="store_true", 71 | dest="debug", 72 | help="Enable debug mode, which will echo a number of hidden processes to the terminal.", 73 | ) 74 | 75 | subparser.add_argument( 76 | "--greet", 77 | action="store_true", 78 | dest="greet", 79 | help="Greet the user on initialization.", 80 | ) 81 | 82 | subparser.add_argument( 83 | "--name", 84 | action="store", 85 | type=str.lower, 86 | dest="name", 87 | help=( 88 | "Give the assistant a name; only for aesthetic purposes, the bot is not informed. Instead, use `--prompt` " 89 | "if you wish to provide it with information." 90 | ), 91 | ) 92 | 93 | 94 | def init_subparser_character(subparser) -> None: 95 | character_descriptions = [i[1] for i in character_choices.values()] 96 | if len(character_descriptions) > 1: 97 | character_descriptions[-1] = f"or {character_descriptions[-1]}" 98 | 99 | character_descriptions = ", ".join(character_descriptions) 100 | subparser.add_argument( 101 | "character", 102 | action="store", 103 | choices=character_choices, 104 | help=f"Choose one of the available characters to interact with them: {character_descriptions}", 105 | type=str.lower, 106 | ) 107 | 108 | 109 | def init_subparser_voice_search(subparser) -> None: 110 | subparser.add_argument( 111 | "--country", 112 | action="store", 113 | choices=AzureNeuralVoiceManager.list_countries(), 114 | dest="country", 115 | help="Filter by country code.", 116 | nargs="*", 117 | type=str.lower, 118 | ) 119 | 120 | subparser.add_argument( 121 | "--gender", 122 | action="store", 123 | choices=AzureNeuralVoiceManager.list_genders(), 124 | dest="gender", 125 | help="Filter by gender.", 126 | nargs="*", 127 | type=str.lower, 128 | ) 129 | 130 | subparser.add_argument( 131 | "--language", 132 | action="store", 133 | choices=AzureNeuralVoiceManager.list_languages(), 134 | dest="language", 135 | help="Filter by language code.", 136 | nargs="*", 137 | type=str.lower, 138 | ) 139 | 140 | subparser.add_argument( 141 | "--region", 142 | action="store", 143 | choices=AzureNeuralVoiceManager.list_regions(), 144 | dest="region", 145 | help="Filter by region name.", 146 | nargs="*", 147 | type=str.lower, 148 | ) 149 | 150 | subparser.add_argument( 151 | "--style", 152 | action="store", 153 | choices=AzureNeuralVoiceManager.list_styles(), 154 | dest="style", 155 | help="Filter by voice style.", 156 | nargs="*", 157 | type=str.lower, 158 | ) 159 | 160 | 161 | def exec_main(args) -> None: 162 | kwargs = { 163 | "model": args.model, 164 | "voice": args.voice, 165 | "system": args.prompt, 166 | "assistant_name": args.name, 167 | } 168 | 169 | if args.debug: 170 | logging.getLogger().setLevel(logging.DEBUG) 171 | 172 | interface = TKInterface(**kwargs) 173 | interface.run(greet=args.greet) 174 | 175 | 176 | def exec_character(args) -> None: 177 | character = args.character.lower().strip() 178 | character_choices[character][0]() 179 | 180 | 181 | def exec_voice_search(args) -> None: 182 | kwargs = { 183 | "gender": args.gender, 184 | "language": args.language, 185 | "country": args.country, 186 | "region": args.region, 187 | "style": args.style, 188 | } 189 | 190 | search_results = sorted(AzureNeuralVoiceManager.search(**kwargs), key=lambda x: x.name) 191 | for voice in search_results: 192 | print(voice, end="\n\n") 193 | 194 | 195 | def run() -> None: 196 | """ 197 | The main function to run the BanterBot Command Line Interface. 198 | 199 | This function parses command line arguments, sets up the necessary configurations, and initializes the BanterBotTK 200 | graphical user interface for user interaction. 201 | """ 202 | parser = argparse.ArgumentParser( 203 | prog="BanterBot", 204 | usage="%(prog)s [options]", 205 | description=( 206 | "BanterBot is an OpenAI ChatGPT-powered chatbot with Azure Neural Voices. Supports speech-to-text and" 207 | " text-to-speech interactions. Create a custom BanterBot with the provided options, or use the" 208 | " `character` command to select a pre-loaded character. For custom BanterBots, use the `voice-search`" 209 | " command to see which voices are available." 210 | ), 211 | epilog=( 212 | "Requires three environment variables for full functionality.\n" 213 | "\n 1) OPENAI_API_KEY: A valid OpenAI API key," 214 | "\n 2) AZURE_SPEECH_KEY: A valid Azure Cognitive Services Speech API key for text-to-speech and " 215 | "speech-to-text functionality," 216 | "\n 3) AZURE_SPEECH_REGION: The region associated with your Azure Cognitive Services Speech API key." 217 | ), 218 | formatter_class=CustomHelpFormatter, 219 | ) 220 | init_parser(parser) 221 | 222 | subparsers = parser.add_subparsers(required=False, dest="command") 223 | 224 | subparser_character = subparsers.add_parser( 225 | "character", 226 | prog="BanterBot Character Loader", 227 | usage="%(prog)s [options]", 228 | description="Select one of the pre-loaded BanterBot characters to begin a conversation.", 229 | formatter_class=CustomHelpFormatter, 230 | epilog=( 231 | "Requires three environment variables for full functionality.\n" 232 | "\n 1) OPENAI_API_KEY: A valid OpenAI API key," 233 | "\n 2) AZURE_SPEECH_KEY: A valid Azure Cognitive Services Speech API key for text-to-speech and " 234 | "speech-to-text functionality," 235 | "\n 3) AZURE_SPEECH_REGION: The region associated with your Azure Cognitive Services Speech API key." 236 | ), 237 | ) 238 | 239 | init_subparser_character(subparser_character) 240 | 241 | subparser_voice_search = subparsers.add_parser( 242 | "voice-search", 243 | prog="BanterBot Voice Search", 244 | usage="%(prog)s [options]", 245 | description=( 246 | "Use this tool to search through available Azure Cognitive Services Neural Voices using the provided " 247 | "search parameters (country, gender, language, region). For more information visit:\n" 248 | "https://learn.microsoft.com/azure/ai-services/speech-service/language-support?tabs=tts" 249 | ), 250 | epilog=( 251 | "Requires two environment variables for voice search:\n" 252 | "\n 1) AZURE_SPEECH_KEY: A valid Azure Cognitive Services Speech API key for text-to-speech and " 253 | "speech-to-text functionality," 254 | "\n 2) AZURE_SPEECH_REGION: The region associated with your Azure Cognitive Services Speech API key." 255 | ), 256 | formatter_class=CustomHelpFormatter, 257 | ) 258 | 259 | init_subparser_voice_search(subparser_voice_search) 260 | 261 | args = parser.parse_args() 262 | 263 | if args.command == "character": 264 | exec_character(args) 265 | elif args.command == "voice-search": 266 | exec_voice_search(args) 267 | else: 268 | exec_main(args) 269 | 270 | 271 | if __name__ == "__main__": 272 | run() 273 | -------------------------------------------------------------------------------- /banterbot/gui/tk_interface.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | import tkinter as tk 5 | import tkinter.simpledialog 6 | from tkinter import ttk 7 | from typing import Optional, Union 8 | 9 | from banterbot.data.prompts import Greetings 10 | from banterbot.extensions.interface import Interface 11 | from banterbot.models.azure_neural_voice_profile import AzureNeuralVoiceProfile 12 | from banterbot.models.openai_model import OpenAIModel 13 | 14 | 15 | class TKInterface(tk.Tk, Interface): 16 | """ 17 | A graphical user interface (GUI) class that enables interaction with the BanterBot chatbot in a multiplayer mode. 18 | It supports functionalities such as text input, text-to-speech and speech-to-text capabilities for up to 9 users 19 | simultaneously, based on OpenAI and Azure services. 20 | 21 | This class inherits from tkinter's Tk class and a custom Interface class, allowing it to be displayed as a 22 | standalone window and follow a specific chatbot interaction protocol respectively. 23 | """ 24 | 25 | def __init__( 26 | self, 27 | model: Optional[OpenAIModel] = None, 28 | voice: Optional[AzureNeuralVoiceProfile] = None, 29 | languages: Optional[Union[str, list[str]]] = None, 30 | tone_model: OpenAIModel = None, 31 | system: Optional[str] = None, 32 | phrase_list: Optional[list[str]] = None, 33 | assistant_name: Optional[str] = None, 34 | ) -> None: 35 | """ 36 | Initialize the TKInterface class, which inherits from both tkinter.Tk and Interface. 37 | 38 | Args: 39 | model (OpenAIModel, optional): The OpenAI model to be used for generating responses. 40 | voice (AzureNeuralVoice, optional): The Azure Neural Voice to be used for text-to-speech. 41 | languages (Optional[Union[str, list[str]]]): The languages supported by the speech-to-text recognizer. 42 | tone_model (OpenAIModel): The OpenAI ChatCompletion model to use for tone evaluation. 43 | system (Optional[str]): An initialization prompt that can be used to set the scene. 44 | phrase_list(list[str], optional): Optionally provide the recognizer with context to improve recognition. 45 | assistant_name (str, optional): Optionally provide a name for the character. 46 | """ 47 | logging.debug(f"TKInterface initialized") 48 | 49 | tk.Tk.__init__(self) 50 | Interface.__init__( 51 | self, 52 | model=model, 53 | voice=voice, 54 | languages=languages, 55 | system=system, 56 | tone_model=tone_model, 57 | phrase_list=phrase_list, 58 | assistant_name=assistant_name, 59 | ) 60 | 61 | # Bind the `_quit` method to program exit, in order to guarantee the stopping of all running threads. 62 | self.protocol("WM_DELETE_WINDOW", self._quit) 63 | 64 | # Flag and lock to indicate whether any keys are currently activating the listener. 65 | self._key_down = False 66 | self._key_down_lock = threading.Lock() 67 | 68 | def listener_activate(self, idx: int) -> None: 69 | with self._key_down_lock: 70 | if not self._key_down: 71 | self._key_down = True 72 | user_name = self.name_entries[idx].get().split(" ")[0].strip() 73 | return super().listener_activate(user_name) 74 | 75 | def listener_deactivate(self) -> None: 76 | self._key_down = False 77 | self.reset_focus() 78 | return super().listener_deactivate() 79 | 80 | def request_response(self) -> None: 81 | if self._messages: 82 | # Interrupt any currently active ChatCompletion, text-to-speech, or speech-to-text streams 83 | self._thread_queue.add_task( 84 | threading.Thread(target=self.respond, kwargs={"init_time": time.perf_counter_ns()}, daemon=True) 85 | ) 86 | 87 | def run(self, greet: bool = False) -> None: 88 | """ 89 | Run the BanterBot application. This method starts the main event loop of the tkinter application. 90 | 91 | Args: 92 | greet (bool): If True, greets the user unprompted on initialization. 93 | """ 94 | if greet: 95 | self.system_prompt(Greetings.UNPROMPTED_GREETING.value) 96 | self.mainloop() 97 | 98 | def select_all_on_focus(self, event) -> None: 99 | widget = event.widget 100 | if widget == self.name_entry: 101 | self._name_entry_focused = True 102 | widget.selection_range(0, tk.END) 103 | widget.icursor(tk.END) 104 | else: 105 | self._name_entry_focused = False 106 | 107 | def update_conversation_area(self, word: str) -> None: 108 | super().update_conversation_area(word) 109 | self.conversation_area["state"] = tk.NORMAL 110 | self.conversation_area.insert(tk.END, word) 111 | self.conversation_area["state"] = tk.DISABLED 112 | self.conversation_area.update_idletasks() 113 | self.conversation_area.see(tk.END) 114 | 115 | def update_name(self, idx: int) -> None: 116 | name = tkinter.simpledialog.askstring("Name", "Enter a Name") 117 | self.names[idx].set(name) 118 | 119 | def reset_focus(self) -> None: 120 | self.panel_frame.focus_set() 121 | 122 | def _quit(self) -> None: 123 | """ 124 | This method is called on exit, and interrupts any currently running activity. 125 | """ 126 | self.interrupt() 127 | self.quit() 128 | self.destroy() 129 | 130 | def _init_gui(self) -> None: 131 | self.title(f"BanterBot {self._model.model}") 132 | self.configure(bg="black") 133 | self.geometry("1024x565") 134 | self._font = ("Cascadia Code", 16) 135 | 136 | self.style = ttk.Style() 137 | self.style.theme_use("clam") 138 | self.style.configure(".", font=self._font, bg="black", fg="white") 139 | self.style.configure("Vertical.TScrollbar", background="black", bordercolor="black", arrowcolor="black") 140 | 141 | self.history_frame = ttk.Frame(self) 142 | self.conversation_area = tk.Text( 143 | self.history_frame, wrap=tk.WORD, state=tk.DISABLED, bg="black", fg="white", font=self._font 144 | ) 145 | self.conversation_area.grid(row=0, column=0, ipadx=5, padx=5, pady=5, sticky="nsew") 146 | self.history_frame.rowconfigure(0, weight=1) 147 | self.history_frame.columnconfigure(0, weight=1) 148 | 149 | self.scrollbar = ttk.Scrollbar(self.history_frame, command=self.conversation_area.yview) 150 | self.scrollbar.grid(row=0, column=1, sticky="ns") 151 | self.conversation_area["yscrollcommand"] = self.scrollbar.set 152 | 153 | self.history_frame.grid(row=0, column=0, padx=10, pady=10, sticky="nsew") 154 | self.rowconfigure(0, weight=1) 155 | self.columnconfigure(0, weight=1) 156 | 157 | self.panel_frame = ttk.Frame(self) 158 | self.panel_frame.grid(row=0, column=1, padx=10, pady=10, sticky="nsew") 159 | 160 | self.name_entries = [] 161 | self.names = [] 162 | self.listen_buttons = [] 163 | self.edit_buttons = [] 164 | 165 | for i in range(9): 166 | name = tk.StringVar() 167 | name.set(f"User {i+1}") 168 | name_entry = tk.Entry( 169 | self.panel_frame, 170 | textvariable=name, 171 | readonlybackground="black", 172 | fg="white", 173 | font=self._font, 174 | width=12, 175 | state="readonly", 176 | takefocus=False, 177 | ) 178 | name_entry.grid(row=i, column=0, padx=(5, 0), pady=5, sticky="nsew") 179 | self.name_entries.append(name_entry) 180 | self.names.append(name) 181 | 182 | listen_button = ttk.Button(self.panel_frame, text="Listen", width=7) 183 | listen_button.grid(row=i, column=2, padx=(0, 5), pady=5, sticky="nsew") 184 | 185 | edit_button = ttk.Button(self.panel_frame, text="✎", width=2) 186 | edit_button.grid(row=i, column=1, padx=(0, 5), pady=5, sticky="nsew") 187 | 188 | edit_button.bind(f"", lambda _, i=i: self.update_name(i)) 189 | edit_button.bind(f"", lambda _: self.reset_focus()) 190 | self.edit_buttons.append(edit_button) 191 | 192 | listen_button.bind(f"", lambda _, i=i: self.listener_activate(i)) 193 | listen_button.bind(f"", lambda _: self.listener_deactivate()) 194 | self.listen_buttons.append(listen_button) 195 | 196 | self.bind(f"", lambda _, i=i: self.listener_activate(i)) 197 | self.bind(f"", lambda _: self.listener_deactivate()) 198 | 199 | self.request_btn = ttk.Button(self.panel_frame, text="Respond", width=7) 200 | self.request_btn.grid(row=9, column=0, padx=(5, 0), pady=5, sticky="nsew") 201 | 202 | self.request_btn.bind(f"", lambda event: self.request_response()) 203 | self.bind("", lambda event: self.request_response()) 204 | 205 | self.reset_focus() 206 | -------------------------------------------------------------------------------- /banterbot/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.handlers.speech_recognition_handler import SpeechRecognitionHandler 2 | from banterbot.handlers.speech_synthesis_handler import SpeechSynthesisHandler 3 | from banterbot.handlers.stream_handler import StreamHandler 4 | 5 | __all__ = ["SpeechRecognitionHandler", "SpeechSynthesisHandler", "StreamHandler"] 6 | -------------------------------------------------------------------------------- /banterbot/handlers/speech_recognition_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | from typing import Generator 4 | 5 | import azure.cognitiveservices.speech as speechsdk 6 | 7 | from banterbot.models.speech_recognition_input import SpeechRecognitionInput 8 | from banterbot.utils.closeable_queue import CloseableQueue 9 | 10 | 11 | class SpeechRecognitionHandler: 12 | """ 13 | A single-use class that handles the speech recognition process. It is typically returned by the `listen` method of 14 | the `SpeechRecognitionService` class. It can be iterated over to yield the sentences as they are recognized. It can 15 | also be closed to stop the speech recognition process. 16 | """ 17 | 18 | def __init__(self, recognizer: speechsdk.SpeechRecognizer, queue: CloseableQueue) -> None: 19 | self._recognizer = recognizer 20 | self._queue = queue 21 | self._iterating = False 22 | self._iterating_lock = threading.Lock() 23 | 24 | def __iter__(self) -> Generator[SpeechRecognitionInput, None, None]: 25 | """ 26 | Iterates over the sentences as they are recognized, yielding them. 27 | 28 | Yields: 29 | Generator[SpeechRecognitionInput, None, None]: The sentences as they are recognized. 30 | """ 31 | 32 | with self._iterating_lock: 33 | if self._iterating: 34 | raise RuntimeError( 35 | "Cannot iterate over an already iterating instance of class `SpeechSynthesisHandler`" 36 | ) 37 | self._iterating = True 38 | 39 | # Start recognizing. 40 | self._recognizer.start_continuous_recognition_async() 41 | logging.debug("SpeechRecognitionHandler recognizer started") 42 | 43 | # Process the sentences as they are recognized. 44 | for speech_recognition_input in self._queue: 45 | yield speech_recognition_input 46 | logging.debug(f"SpeechRecognitionHandler yielded: `{speech_recognition_input}`") 47 | 48 | def close(self) -> None: 49 | """ 50 | Closes the speech synthesis process. 51 | """ 52 | self._recognizer.stop_continuous_recognition_async() 53 | -------------------------------------------------------------------------------- /banterbot/handlers/speech_synthesis_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | from typing import Generator, Optional 5 | 6 | import azure.cognitiveservices.speech as speechsdk 7 | import numba as nb 8 | 9 | from banterbot.models.phrase import Phrase 10 | from banterbot.models.word import Word 11 | from banterbot.utils.closeable_queue import CloseableQueue 12 | 13 | 14 | class SpeechSynthesisHandler: 15 | """ 16 | A single-use class that handles the speech synthesis process. It is typically returned by the `speak` method of the 17 | `SpeechSynthesisService` class. It can be iterated over to yield the words as they are synthesized. It can also be 18 | closed to stop the speech synthesis process. 19 | """ 20 | 21 | def __init__(self, phrases: list[Phrase], synthesizer: speechsdk.SpeechSynthesizer, queue: CloseableQueue) -> None: 22 | """ 23 | Initializes a `SpeechSynthesisHandler` instance. 24 | 25 | Args: 26 | synthesizer (speechsdk.SpeechSynthesizer): The speech synthesizer to use for speech synthesis. 27 | queue (CloseableQueue): The queue to use for storing the words as they are synthesized. 28 | """ 29 | self._synthesizer = synthesizer 30 | self._queue = queue 31 | self._iterating = False 32 | self._iterating_lock = threading.Lock() 33 | 34 | # Convert the phrases into SSML 35 | self._ssml = self._phrases_to_ssml(phrases) 36 | 37 | def __iter__(self) -> Generator[Word, None, None]: 38 | """ 39 | Iterates over the words as they are synthesized, yielding each word as it is synthesized. 40 | 41 | Args: 42 | phrases (list[Phrase]): The phrases to be synthesized. 43 | 44 | Yields: 45 | Generator[Word, None, None]: The words as they are synthesized. 46 | """ 47 | 48 | with self._iterating_lock: 49 | if self._iterating: 50 | raise RuntimeError( 51 | "Cannot iterate over an already iterating instance of class `SpeechSynthesisHandler`" 52 | ) 53 | self._iterating = True 54 | 55 | # Start synthesizing. 56 | self._synthesizer.start_speaking_ssml_async(self._ssml) 57 | logging.debug("SpeechSynthesisHandler synthesizer started") 58 | 59 | # Process the words as they are synthesized. 60 | for item in self._queue: 61 | # Determine if a delay is needed to match the word's offset. 62 | dt = 1e-9 * (item["time"] - time.perf_counter_ns()) 63 | # If a delay is needed, wait for the specified time. 64 | time.sleep(dt if dt >= 0 else 0) 65 | 66 | # Yield the word. 67 | yield item["word"] 68 | logging.debug(f"SpeechSynthesisHandler yielded word: `{item['word']}`") 69 | 70 | self._synthesizer.stop_speaking_async() 71 | 72 | @staticmethod 73 | @nb.njit(cache=True) 74 | def _jit_phrases_to_ssml( 75 | texts: list[Optional[str]], 76 | short_names: list[Optional[str]], 77 | pitches: list[Optional[str]], 78 | rates: list[Optional[str]], 79 | styles: list[Optional[str]], 80 | styledegrees: list[Optional[str]], 81 | emphases: list[Optional[str]], 82 | ) -> str: 83 | """ 84 | Creates a more advanced SSML string from the specified list of `Phrase` instances, that customizes the emphasis, 85 | style, pitch, and rate of speech on a sub-sentence level, including pitch contouring between phrases. Uses Numba 86 | to speed up the process. 87 | 88 | Args: 89 | texts (list[Optional[str]]): The texts to be synthesized. 90 | short_names (list[Optional[str]]): The short names of the voices to use for each phrase. 91 | pitches (list[Optional[str]]): The pitches to use for each phrase. 92 | rates (list[Optional[str]]): The rates to use for each phrase. 93 | styles (list[Optional[str]]): The styles to use for each phrase. 94 | styledegrees (list[Optional[str]]): The style degrees to use for each phrase. 95 | emphases (list[Optional[str]]): The emphases to use for each phrase. 96 | 97 | Returns: 98 | str: The SSML string. 99 | """ 100 | # Start the SSML string with the required header 101 | ssml = ( 102 | '' 106 | ) 107 | 108 | # Iterate over the phrases and add the SSML tags 109 | for n, (text, short_name, pitch, rate, style, styledegree, emphasis) in enumerate( 110 | zip(texts, short_names, pitches, rates, styles, styledegrees, emphases) 111 | ): 112 | # Add contour only if there is a pitch transition 113 | if pitch: 114 | if n < len(pitches) - 1 and pitches[n + 1] and pitch != pitches[n + 1]: 115 | # Set the contour to begin transition at 50% of the current phrase to match the pitch of the next one. 116 | pitch = ' contour="(50%,' + pitch + ") (80%," + pitches[n + 1] + ')"' 117 | else: 118 | pitch = ' pitch="' + pitch + '"' 119 | else: 120 | pitch = "" 121 | 122 | # Add the voice and other tags along with prosody 123 | ssml += '' 124 | ssml += '' 125 | ssml += '' 126 | ssml += '' 127 | ssml += '' 128 | 129 | # Add the express-as tag if style and styledegree are specified 130 | if style and styledegree: 131 | ssml += '' 132 | if pitch or rate: 133 | rate_value = rate if rate else "" 134 | ssml += "' 135 | if emphasis: 136 | ssml += '' 137 | 138 | ssml += text 139 | 140 | # Close the tags 141 | if emphasis: 142 | ssml += "" 143 | if pitch or rate: 144 | ssml += "" 145 | if style and styledegree: 146 | ssml += "" 147 | ssml += "" 148 | 149 | # Close the voice and speak tags and return the SSML string 150 | ssml += "" 151 | return ssml 152 | 153 | @classmethod 154 | def _phrases_to_ssml(cls, phrases: list[Phrase]) -> str: 155 | """ 156 | Creates a more advanced SSML string from the specified list of `Phrase` instances, that customizes the emphasis, 157 | style, pitch, and rate of speech on a sub-sentence level, including pitch contouring between phrases. Calls the 158 | 'jit_phrases_to_ssml' method to speed up the process using Numba. 159 | 160 | Args: 161 | phrases (list[Phrase]): Instances of class `Phrase` that contain data that can be converted into speech. 162 | 163 | Returns: 164 | str: The SSML string. 165 | """ 166 | texts, short_names, pitches, rates, styles, styledegrees, emphases = zip(*[ 167 | ( 168 | phrase.text, 169 | phrase.voice.short_name, 170 | phrase.pitch, 171 | phrase.rate, 172 | phrase.style, 173 | phrase.styledegree, 174 | phrase.emphasis, 175 | ) 176 | for phrase in phrases 177 | ]) 178 | 179 | return cls._jit_phrases_to_ssml(texts, short_names, pitches, rates, styles, styledegrees, emphases) 180 | -------------------------------------------------------------------------------- /banterbot/handlers/stream_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | from typing import Generator 5 | 6 | from banterbot.models.number import Number 7 | from banterbot.utils.closeable_queue import CloseableQueue 8 | 9 | 10 | class StreamHandler: 11 | """ 12 | Handler for managing and interacting with a data stream. 13 | """ 14 | 15 | def __init__( 16 | self, 17 | interrupt: Number, 18 | kill_event: threading.Event, 19 | queue: CloseableQueue, 20 | processor_thread: threading.Thread, 21 | shared_data: dict, 22 | ) -> None: 23 | """ 24 | Initializes the stream handler with the given parameters. This should not be called directly, but rather 25 | through the `StreamHandler.create` method. This is because the `StreamHandler` class is not thread-safe. 26 | 27 | Args: 28 | interrupt (Number): The shared interrupt value. 29 | kill_event (threading.Event): The shared kill event. 30 | queue (CloseableQueue): The shared queue. 31 | processor_thread (threading.Thread): The shared processor thread. 32 | shared_data (dict): The shared data. 33 | """ 34 | self._interrupt = interrupt 35 | self._kill_event = kill_event 36 | self._queue = queue 37 | self._shared_data = shared_data 38 | self._processor_thread = processor_thread 39 | 40 | def __iter__(self) -> Generator[CloseableQueue]: 41 | """ 42 | Inherits the `__iter__` method from the `CloseableQueue` class to allow for iteration over the stream handler. 43 | """ 44 | # Start the processor thread. 45 | self._processor_thread.start() 46 | # Prevent multiple iterations over the stream handler.¨ 47 | logging.debug(f"StreamHandler iterating") 48 | # Return the queue for iteration as a generator. 49 | for item in self._queue: 50 | yield item 51 | 52 | def is_alive(self) -> bool: 53 | """ 54 | Returns whether the stream handler is alive or not. 55 | 56 | Returns: 57 | bool: Whether the stream handler is alive or not. 58 | """ 59 | return not self._queue.finished() 60 | 61 | def interrupt(self, kill: bool = False) -> None: 62 | """ 63 | Interrupt the active stream by setting the interrupt value to the current time and setting the kill event. 64 | 65 | Args: 66 | kill (bool): Whether to kill the queue or not. Defaults to False. 67 | """ 68 | self._kill_event.set() 69 | self._interrupt.set(time.perf_counter_ns()) 70 | self._shared_data["interrupt"] = self._interrupt.value 71 | logging.debug(f"StreamHandler interrupted") 72 | 73 | if kill: 74 | self._queue.kill() 75 | logging.debug(f"StreamHandler killed") 76 | -------------------------------------------------------------------------------- /banterbot/managers/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.managers.azure_neural_voice_manager import AzureNeuralVoiceManager 2 | from banterbot.managers.memory_chain import MemoryChain 3 | from banterbot.managers.openai_model_manager import OpenAIModelManager 4 | from banterbot.managers.resource_manager import ResourceManager 5 | from banterbot.managers.stream_manager import StreamManager 6 | 7 | __all__ = ["AzureNeuralVoiceManager", "MemoryChain", "OpenAIModelManager", "ResourceManager", "StreamManager"] 8 | -------------------------------------------------------------------------------- /banterbot/managers/azure_neural_voice_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import re 4 | from itertools import chain 5 | from typing import Optional, Union 6 | 7 | import azure.cognitiveservices.speech as speechsdk 8 | 9 | from banterbot.data.enums import EnvVar 10 | from banterbot.models.azure_neural_voice_profile import AzureNeuralVoiceProfile 11 | 12 | 13 | class AzureNeuralVoiceManager: 14 | """ 15 | Management utility for loading Microsoft Azure Cognitive Services Neural Voice models from the Speech SDK. Only one 16 | instance per name is permitted to exist at a time, and loading occurs lazily, meaning that when the voices are 17 | downloaded, they are subsequently stored in cache as instances of class `AzureNeuralVoice`, and all future calls 18 | refer to these same cached instances. 19 | """ 20 | 21 | _data = {} 22 | 23 | @classmethod 24 | def _download(cls) -> None: 25 | """ 26 | Download all the Neural Voices to cache as instances of class `AzureNeuralVoice` using the `get_voices_async` 27 | method from the Microsoft Azure Cognitive Services Speech SDK class `SpeechSynthesizer`. 28 | """ 29 | speech_config = speechsdk.SpeechConfig( 30 | subscription=os.environ.get(EnvVar.AZURE_SPEECH_KEY.value), 31 | region=os.environ.get(EnvVar.AZURE_SPEECH_REGION.value), 32 | ) 33 | result_future = speechsdk.SpeechSynthesizer(speech_config=speech_config).get_voices_async() 34 | synthesis_voices_result = result_future.get() 35 | 36 | # Regex pattern that extracts language, country, region, and a key from each voice's `short_name` attribute, 37 | pattern = re.compile(r"([a-z]+)\-([A-Z]+)\-(?:(\w+)\-)?(\w+?)Neural") 38 | 39 | for voice in synthesis_voices_result.voices: 40 | # Specify that we only want to store Neural Voices. 41 | if voice.voice_type == speechsdk.SynthesisVoiceType.OnlineNeural: 42 | match = re.fullmatch(pattern=pattern, string=voice.short_name) 43 | 44 | if not match: 45 | logging.debug(f"Unable to parse Azure Cognitive Services Neural Voice: {voice.short_name}.") 46 | else: 47 | language = match[1] 48 | country = match[2] 49 | region = match[3] 50 | name = match[4] 51 | 52 | if name in cls._data: 53 | key = name + "-" + region 54 | else: 55 | key = name 56 | 57 | cls._data[key.lower()] = AzureNeuralVoiceProfile( 58 | country=country, 59 | description=voice.name, 60 | gender=voice.gender, 61 | language=language, 62 | locale=voice.locale, 63 | name=name, 64 | short_name=voice.short_name.lower(), 65 | style_list=voice.style_list if voice.style_list else None, 66 | region=region, 67 | ) 68 | 69 | @classmethod 70 | def data(cls) -> dict[str, AzureNeuralVoiceProfile]: 71 | """ 72 | Access the data dictionary, downloading it first using the `_download` classmethod if necessary. 73 | 74 | Returns: 75 | dict[str, AzureNeuralVoiceProfile]: A dict containing the downloaded `AzureNeuralVoiceProfile` instances. 76 | """ 77 | if not cls._data: 78 | cls._download() 79 | 80 | return cls._data 81 | 82 | @classmethod 83 | def _preprocess_search_arg(cls, arg: Optional[Union[list[str], str]] = None) -> Optional[Union[list[str], str]]: 84 | """ 85 | Prepare an arbitrary argument given to the `search` method by lowering its value(s). 86 | 87 | Args: 88 | arg (Optional[Union[list[str], str]]): A string, list of strings, or None value. 89 | 90 | Returns: 91 | Optional[Union[list[str], str]]: The same input but lowered, if applicable. 92 | """ 93 | if not arg: 94 | return None 95 | elif isinstance(arg, str): 96 | return [arg.lower()] 97 | elif isinstance(arg, (list, tuple, set)): 98 | return sorted([i.lower() for i in arg]) 99 | 100 | @classmethod 101 | def search( 102 | cls, 103 | gender: Optional[Union[list[str], str]] = None, 104 | language: Optional[Union[list[str], str]] = None, 105 | country: Optional[Union[list[str], str]] = None, 106 | region: Optional[Union[list[str], str]] = None, 107 | style: Optional[Union[list[str], str]] = None, 108 | ) -> list[AzureNeuralVoiceProfile]: 109 | """ 110 | Search through all the available Microsoft Azure Cognitive Services Neural Voice models using any combination 111 | of the provided arguments to get a list of relevant `AzureNeuralVoiceProfile` instances. For information on 112 | searchable languages, countries, and regions, visit: 113 | 114 | https://learn.microsoft.com/en-us/azure/ai-services/speech-service/language-support?tabs=tts#supported-languages 115 | 116 | Args: 117 | gender (Optional[Union[list[str], str]]): Can take the values MALE, FEMALE, and/or UNKNOWN. 118 | language (Optional[Union[list[str], str]]): Can take any language abbreviations (e.g., en, fr, etc.) 119 | country (Optional[Union[list[str], str]]): Can take any country abbreviations (e.g., US, FR, etc.) 120 | region (Optional[Union[list[str], str]]): Can take any region names (e.g., shaanxi, sichuan, etc.) 121 | 122 | Returns: 123 | list[AzureNeuralVoiceProfile]: A list of `AzureNeuralVoiceProfile` instances. 124 | """ 125 | search_results = [] 126 | 127 | # Convert any provided string values to lowercase if applicable. 128 | gender = cls._preprocess_search_arg(arg=gender) 129 | language = cls._preprocess_search_arg(arg=language) 130 | country = cls._preprocess_search_arg(arg=country) 131 | region = cls._preprocess_search_arg(arg=region) 132 | style = cls._preprocess_search_arg(arg=style) 133 | 134 | for voice in cls.data().values(): 135 | # Convert the voice attributes to lowercase if applicable. 136 | voice_gender = voice.gender.name.lower() if voice.gender.name else None 137 | voice_language = voice.language.lower() if voice.language else None 138 | voice_country = voice.country.lower() if voice.country else None 139 | voice_region = voice.region.lower() if voice.region else None 140 | voice_styles = sorted([s.lower() for s in voice.style_list]) if voice.style_list else [] 141 | 142 | # Prepare a set of search conditions. 143 | condition_gender = gender is None or voice_gender in gender 144 | condition_language = language is None or voice_language in language 145 | condition_country = country is None or voice_country in country 146 | condition_region = region is None or voice_region in region 147 | condition_styles = style is None or all(s in voice_styles for s in style) 148 | 149 | # If all conditions are met, add the voice to the search results. 150 | if all([condition_gender, condition_language, condition_country, condition_region, condition_styles]): 151 | search_results.append(voice) 152 | 153 | return search_results 154 | 155 | @classmethod 156 | def list_countries(cls) -> list[str]: 157 | """ 158 | Returns a list of two-character country codes (e.g., us, fr, etc.) 159 | 160 | Returns: 161 | list[str]: A list of country codes. 162 | """ 163 | voices = cls.data() 164 | return sorted(list(set(voice.country for voice in voices.values() if voice.country))) 165 | 166 | @classmethod 167 | def list_genders(cls) -> list[str]: 168 | """ 169 | Returns a list of available voice genders 170 | 171 | Returns: 172 | list[str]: A list of genders. 173 | """ 174 | voices = cls.data() 175 | return sorted(list(set(voice.gender.name for voice in voices.values() if voice.gender.name))) 176 | 177 | @classmethod 178 | def list_languages(cls) -> list[str]: 179 | """ 180 | Returns a list of two-character language codes (e.g., en, fr, etc.) 181 | 182 | Returns: 183 | list[str]: A list of language codes. 184 | """ 185 | voices = cls.data() 186 | return sorted(list(set(voice.language for voice in voices.values() if voice.language))) 187 | 188 | @classmethod 189 | def list_locales(cls) -> list[str]: 190 | """ 191 | Returns a list of locales, which are language codes followed by countries, in some cases followed by a region, 192 | (e.g., en-US fr-FR, etc.). 193 | 194 | Returns: 195 | list[str]: A list of locales. 196 | """ 197 | voices = cls.data() 198 | return sorted(list(set(voice.locale for voice in voices.values() if voice.locale))) 199 | 200 | @classmethod 201 | def list_regions(cls) -> list[str]: 202 | """ 203 | Returns a list of regions (e.g., sichuan, shandong, etc.) 204 | 205 | Returns: 206 | list[str]: A list of regions. 207 | """ 208 | voices = cls.data() 209 | return sorted(list(set(voice.region for voice in voices.values() if voice.region))) 210 | 211 | @classmethod 212 | def list_styles(cls) -> list[str]: 213 | """ 214 | Returns a list of styles (e.g., sichuan, shandong, etc.) 215 | 216 | Returns: 217 | list[str]: A list of styles. 218 | """ 219 | voices = cls.data() 220 | return sorted(list(set(chain.from_iterable(voice.style_list for voice in voices.values() if voice.style_list)))) 221 | 222 | @classmethod 223 | def load(cls, name: str) -> AzureNeuralVoiceProfile: 224 | """ 225 | Retrieve or initialize an `AzureNeuralVoice` instance by a name in the Neural Voices resource JSON. 226 | 227 | Args: 228 | name (str): The name of the voice profile. 229 | 230 | Returns: 231 | AzureNeuralVoice: An `AzureNeuralVoice` instance loaded with data from the specified name. 232 | 233 | Raises: 234 | KeyError: If the specified name is not found in the resource file defined by `config.azure_neural_voices`. 235 | """ 236 | voices = cls.data() 237 | if (name := name.lower()) not in voices: 238 | message = ( 239 | "BanterBot was unable to locate a Microsoft Azure Cognitive Services Neural Voice model named: " 240 | f"`{name}`. Use AzureNeuralVoiceManager.search(gender, language, country, region) to search for a " 241 | "specified gender, language, country, and/or region. These arguments can be strings, lists of strings, " 242 | "or None." 243 | ) 244 | raise KeyError(message) 245 | 246 | return voices[name] 247 | -------------------------------------------------------------------------------- /banterbot/managers/memory_chain.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | from typing import Optional 4 | 5 | from typing_extensions import Self 6 | 7 | import banterbot.paths 8 | from banterbot import config 9 | from banterbot.models.memory import Memory 10 | from banterbot.protos import memory_pb2 11 | from banterbot.utils.nlp import NLP 12 | 13 | 14 | class MemoryChain: 15 | """ 16 | MemoryChain is a class responsible for managing and handling arrays of memories using Protocol Buffers. It provides 17 | functionality to save memories to a binary file, load memories from a binary file, and retrieve memories based on 18 | keywords. The MemoryChain class is designed to efficiently store and retrieve memories based on keywords, allowing 19 | for quick access to relevant information. 20 | """ 21 | 22 | @classmethod 23 | def create(cls) -> Self: 24 | """ 25 | Generate a new empty set of memories and associated UUID. 26 | 27 | Returns: 28 | MemoryChain: A new instance of MemoryChain with an empty set of memories and a unique UUID. 29 | """ 30 | uuid = config.generate_uuid().hex 31 | 32 | # Create new directory for the new UUID and memory files 33 | directory = banterbot.paths.personae / uuid / banterbot.paths.memories 34 | directory.mkdir(exist_ok=True, parents=True) 35 | 36 | # Create new memory index file 37 | memory_index = memory_pb2.MemoryIndex() 38 | with open(banterbot.paths.personae / uuid / banterbot.paths.memory_index, "wb+") as fs: 39 | fs.write(memory_index.SerializeToString()) 40 | 41 | logging.debug(f"MemoryChain created new UUID: `{uuid}`") 42 | return cls(uuid=uuid, memory_index={}) 43 | 44 | @classmethod 45 | def load(cls, uuid: str) -> Self: 46 | """ 47 | Load the memories from a binary file using Protocol Buffers deserialization and creates a MemoryChain instance. 48 | This method is used to load an existing set of memories from a file, allowing for the continuation of a previous 49 | session or the sharing of memories between different instances. 50 | 51 | Args: 52 | uuid (str): The UUID of the memory files to load. 53 | 54 | Returns: 55 | MemoryChain: A new instance of MemoryChain with loaded memories. 56 | """ 57 | logging.debug(f"MemoryChain loading UUID: `{uuid}`") 58 | 59 | # Read memory index file 60 | memory_index_object = memory_pb2.MemoryIndex() 61 | with open(banterbot.paths.personae / uuid / banterbot.paths.memory_index, "rb") as fs: 62 | memory_index_object.ParseFromString(fs.read()) 63 | 64 | # Parse the memory index file into a dictionary 65 | memory_index = {entry.keyword: list(entry.memory_uuids) for entry in memory_index_object.entries} 66 | 67 | return cls(uuid, memory_index) 68 | 69 | @classmethod 70 | def delete(cls, uuid: str) -> None: 71 | """ 72 | Delete the directory associated with a MemoryChain instance. This method is used to clean up the file system 73 | by removing the directory and all its contents, including memory files and the memory index file. 74 | 75 | Args: 76 | uuid (str): The UUID associated with this set of memories. 77 | """ 78 | shutil.rmtree(banterbot.paths.personae / uuid) 79 | 80 | def __init__(self, uuid: str, memory_index: dict[str, list[str]]) -> None: 81 | """ 82 | Initialize a new instance of MemoryChain. 83 | 84 | Args: 85 | uuid (str): The UUID associated with this set of memories. 86 | 87 | memory_index (dict[str, list[str]]): The dictionary mapping from keyword to list of memory UUIDs. This index 88 | is used to efficiently look up memories based on keywords. 89 | """ 90 | logging.debug(f"MemoryChain initialized with UUID: `{uuid}`") 91 | self.uuid = uuid 92 | self._directory = banterbot.paths.personae / self.uuid / banterbot.paths.memories 93 | self._index_cache = memory_index 94 | self._memories = {} 95 | self._similarity_cache = {} 96 | self._token_cache = {} 97 | self._update_token_cache(self._index_cache.keys()) 98 | self._find_memories() 99 | 100 | def append(self, memory: Memory) -> None: 101 | """ 102 | Append a memory to the current set of memories. This method is used to add a single memory to the MemoryChain, 103 | allowing for the storage of new information. All changes are saved to file as soon as they are made. 104 | 105 | Args: 106 | memory (Memory): The memory to append. 107 | """ 108 | self._memories[memory.uuid] = memory 109 | self._save_memory(memory=memory) 110 | self._update_index(memory=memory) 111 | self._save_index() 112 | 113 | def extend(self, memories: list[Memory]) -> None: 114 | """ 115 | Extend the current set of memories with a list of memories. This method is used to add multiple memories to the 116 | MemoryChain at once, allowing for the storage of new information in bulk. All changes are saved to file as soon 117 | as they are made. 118 | 119 | Args: 120 | memories (list[Memory]): The list of memories to append. 121 | """ 122 | for memory in memories: 123 | self._memories[memory.uuid] = memory 124 | self._save_memory(memory=memory) 125 | self._update_index(memory=memory) 126 | self._save_index() 127 | 128 | def search(self, keywords: list[str], fuzzy_threshold: Optional[float] = None) -> list[Memory]: 129 | """ 130 | Look up memories based on keywords. This method is used to retrieve memories that are relevant to the specified 131 | keywords. It can also perform fuzzy matching, allowing for the retrieval of memories that are similar to the 132 | given keywords based on a similarity threshold. 133 | 134 | Args: 135 | keywords (list[str]): The list of keywords to look up. 136 | 137 | fuzzy_threshold (Optional[float]): The threshold for fuzzy matching. If None, only returns exact matches. If 138 | a value is provided, memories with keywords that have a similarity score greater than or equal to the 139 | threshold will also be returned. 140 | 141 | Returns: 142 | list[Memory]: The list of matching memories. 143 | """ 144 | memory_uuids = set() 145 | memories = [] 146 | if fuzzy_threshold is not None: 147 | self._update_similarity_cache(keywords=keywords) 148 | 149 | # Find additional keywords that are similar to the specified keywords 150 | keywords_extension = [] 151 | cache_filtered = [i for i in self._index_cache.keys() if i not in keywords] 152 | for keyword in keywords: 153 | for keyword_indexed in cache_filtered: 154 | if self._similarity_cache[(keyword, keyword_indexed)] >= fuzzy_threshold: 155 | keywords_extension.append(keyword_indexed) 156 | keywords.extend(keywords_extension) 157 | 158 | # Add memory UUIDs to the result set 159 | for keyword in keywords: 160 | if keyword in self._index_cache.keys(): 161 | for memory_uuid in self._index_cache[keyword]: 162 | if self._memories[memory_uuid] is None: 163 | self._load_memory(memory_uuid=memory_uuid) 164 | memory_uuids.add(memory_uuid) 165 | 166 | # Write all the Memory objects into a list 167 | for memory_uuid in memory_uuids: 168 | memories.append(self._memories[memory_uuid]) 169 | 170 | return memories 171 | 172 | def _save_memory(self, memory: Memory) -> None: 173 | """ 174 | Save an instance of class Memory to file using protocol buffers. 175 | """ 176 | filename = memory.uuid + banterbot.paths.protobuf_extension 177 | with open(self._directory / filename, "wb+") as fs: 178 | fs.write(memory.serialize()) 179 | 180 | def _save_index(self) -> None: 181 | """ 182 | Save the current state of the memory index to file. 183 | """ 184 | memory_index = memory_pb2.MemoryIndex() 185 | for keyword, memory_uuids in self._index_cache.items(): 186 | memory_index_entry = memory_pb2.MemoryIndexEntry() 187 | memory_index_entry.keyword = keyword 188 | memory_index_entry.memory_uuids.extend(memory_uuids) 189 | memory_index.entries.append(memory_index_entry) 190 | 191 | with open(banterbot.paths.personae / self.uuid / banterbot.paths.memory_index, "wb+") as fs: 192 | fs.write(memory_index.SerializeToString()) 193 | 194 | def _find_memories(self) -> None: 195 | """ 196 | Find all memory files associated with this UUID and store them in _memories dictionary. This method is used to 197 | locate all memory files that belong to the current MemoryChain instance, allowing for the efficient loading and 198 | retrieval of memories when needed. 199 | """ 200 | directory = self._directory 201 | self._memories = {path.stem: None for path in directory.glob("*" + banterbot.paths.protobuf_extension)} 202 | 203 | def _update_index(self, memory: Memory) -> None: 204 | """ 205 | Update the memory index with a new memory. This method is used to keep the memory index up-to-date when new 206 | memories are added to the MemoryChain. The index allows for efficient look-up of memories based on keywords. 207 | 208 | Args: 209 | memory (Memory): The memory to update the index with. 210 | """ 211 | for keyword in memory.keywords: 212 | if keyword not in self._index_cache.keys(): 213 | self._index_cache[keyword] = set() 214 | self._index_cache[keyword].add(memory.uuid) 215 | self._update_token_cache(memory.keywords) 216 | 217 | def _load_memory(self, memory_uuid: str) -> None: 218 | """ 219 | Load a memory from a memory file. This method is used to load a specific memory from a file when it is needed, 220 | allowing for efficient memory usage by only loading memories when they are required. 221 | 222 | Args: 223 | memory_uuid (str): The UUID of the memory to load. 224 | """ 225 | filename = memory_uuid + banterbot.paths.protobuf_extension 226 | with open(self._directory / filename, "rb") as fs: 227 | self._memories[memory_uuid] = Memory.deserialize(fs.read()) 228 | 229 | def _update_token_cache(self, keywords: list[str]) -> None: 230 | """ 231 | Update the token cache with new keywords. This method is used to keep the token cache up-to-date when new 232 | keywords are added to the MemoryChain. The token cache allows for efficient computation of similarity scores 233 | between keywords. 234 | 235 | Args: 236 | keywords (list[str]): The new keywords to update the cache with. 237 | """ 238 | new_keywords = [keyword for keyword in keywords if keyword not in self._token_cache.keys()] 239 | for keyword, token in zip(new_keywords, NLP.tokenize(strings=new_keywords)): 240 | self._token_cache[keyword] = token 241 | 242 | def _update_similarity_cache(self, keywords: list[str]) -> None: 243 | """ 244 | Update the similarity cache with new keywords. This method is used to keep the similarity cache up-to-date when 245 | new keywords are added to the MemoryChain. The similarity cache allows for efficient computation of similarity 246 | scores between keywords, enabling fuzzy matching in the search method. 247 | 248 | Args: 249 | keywords (list[str]): The new keywords to update the cache with. 250 | """ 251 | self._update_token_cache(keywords=keywords) 252 | for keyword_indexed in self._index_cache.keys(): 253 | for keyword in keywords: 254 | pair = (keyword, keyword_indexed) 255 | if pair not in self._similarity_cache.keys(): 256 | similarity = self._token_cache[keyword].similarity(self._token_cache[keyword_indexed]) 257 | self._similarity_cache[pair] = similarity 258 | -------------------------------------------------------------------------------- /banterbot/managers/openai_model_manager.py: -------------------------------------------------------------------------------- 1 | from banterbot import paths 2 | from banterbot.managers.resource_manager import ResourceManager 3 | from banterbot.models.openai_model import OpenAIModel 4 | 5 | 6 | class OpenAIModelManager: 7 | """ 8 | Management utility for loading OpenAI ChatCompletion models from the resource JSON specified by 9 | `config.openai_models`. Only one instance per name is permitted to exist at a time, and loading occurs lazily, 10 | meaning that when a name is loaded, it is subsequently stored in cache and all future calls refer to the cached 11 | instance. 12 | """ 13 | 14 | _data = {} 15 | 16 | @classmethod 17 | def list(cls) -> list[str]: 18 | """ 19 | List the names of all the available OpenAI ChatCompletion models. 20 | 21 | Returns: 22 | list[str]: A list of names. 23 | """ 24 | openai_models = ResourceManager.load_json(filename=paths.openai_models) 25 | return list(openai_models.keys()) 26 | 27 | @classmethod 28 | def load(cls, name: str) -> OpenAIModel: 29 | """ 30 | Retrieve or initialize an `OpenAIModel` instance by a name in the OpenAIModels resource JSON. 31 | 32 | Args: 33 | name (str): The name of the OpenAI ChatCompletion model. 34 | 35 | Returns: 36 | OpenAIModel: An `OpenAIModel` instance loaded with data from the specified name. 37 | 38 | Raises: 39 | KeyError: If the specified name is not found in the resource file defined by `config.openai_models`. 40 | """ 41 | if name.lower() not in cls._data: 42 | openai_models = ResourceManager.load_json(filename=paths.openai_models) 43 | 44 | if name.lower() not in openai_models: 45 | available_models = ", ".join(f"`{name}`" for name in openai_models) 46 | message = ( 47 | f"BanterBot was unable to locate an OpenAI ChatCompletion model named: `{name}`, available models " 48 | f"are: {available_models}." 49 | ) 50 | raise KeyError(message) 51 | 52 | model_data = openai_models[name.lower()] 53 | model = OpenAIModel( 54 | model=model_data["model"], 55 | max_tokens=model_data["max_tokens"], 56 | generation=model_data["generation"], 57 | rank=model_data["rank"], 58 | ) 59 | cls._data[name.lower()] = model 60 | 61 | return cls._data[name.lower()] 62 | -------------------------------------------------------------------------------- /banterbot/managers/resource_manager.py: -------------------------------------------------------------------------------- 1 | import csv 2 | import importlib.resources 3 | import json 4 | from io import StringIO 5 | from typing import Any 6 | 7 | import banterbot.resources 8 | from banterbot import config 9 | 10 | 11 | class ResourceManager: 12 | """ 13 | An interface to simplify loading resources from the `/banterbot/resources/` data directory. In addition to 14 | syntactically simplifying the process, this class gives the option to cache the loaded files to reduce overhead on 15 | future calls. 16 | """ 17 | 18 | _raw_data: dict[str] = {} 19 | _csv_data: dict[list[list[str]]] = {} 20 | _json_data: dict[dict[Any]] = {} 21 | 22 | @classmethod 23 | def reset_cache(cls) -> None: 24 | """ 25 | Reset the entire cache by deleting the contents of the `ResourceLoader._raw_data`, `ResourceLoader._csv_data`, 26 | and `ResourceLoader._json_data` dicts. 27 | """ 28 | cls.reset_raw_cache() 29 | cls.reset_csv_cache() 30 | cls.reset_json_cache() 31 | 32 | @classmethod 33 | def reset_raw_cache(cls) -> None: 34 | """ 35 | Reset the raw data cache by deleting the contents of the `ResourceLoader._raw_data` dict. 36 | """ 37 | cls._raw_data.clear() 38 | 39 | @classmethod 40 | def reset_csv_cache(cls) -> None: 41 | """ 42 | Reset the CSV data cache by deleting the contents of the `ResourceLoader._csv_data` dict. 43 | """ 44 | cls._csv_data.clear() 45 | 46 | @classmethod 47 | def reset_json_cache(cls) -> None: 48 | """ 49 | Reset the JSON data cache by deleting the contents of the `ResourceLoader._json_data` dict. 50 | """ 51 | cls._json_data.clear() 52 | 53 | @classmethod 54 | def load_raw(cls, filename: str, cache: bool = True, reset: bool = False, encoding: str = "utf-8") -> str: 55 | """ 56 | Load a specified file by filename and return its contents as a string. 57 | 58 | Args: 59 | filename (str): The name of the resource file — should including its suffix. 60 | cache (bool): If True, cache the loaded data to reduce overhead the next time its loaded. 61 | reset (bool): If set to True, reloads the contents from file, disregarding the current state of the cache. 62 | encoding (str): The type of encoding to use when loading the resource. 63 | 64 | Returns: 65 | str: The resource file's contents as a string. 66 | """ 67 | if reset or filename not in cls._raw_data: 68 | path = importlib.resources.files(banterbot.resources).joinpath(filename) 69 | try: 70 | with open(file=path, mode="r", encoding=config.ENCODING) as fs: 71 | data = fs.read() 72 | except FileNotFoundError: 73 | message = f"Class `ResourceLoader` found no such resource: `{filename}`" 74 | raise FileNotFoundError(message) 75 | 76 | if cache: 77 | cls._raw_data[filename] = data 78 | else: 79 | data = cls._raw_data[filename] 80 | 81 | return data 82 | 83 | @classmethod 84 | def load_json(cls, filename: str, cache: bool = True, reset: bool = False, encoding: str = "utf-8") -> dict[Any]: 85 | """ 86 | Load a specified JSON file by filename and return its contents as a dictionary. 87 | 88 | Args: 89 | filename (str): The name of the resource file — should be a JSON file. 90 | cache (bool): If True, cache the loaded data to reduce overhead the next time its loaded. 91 | reset (bool): If set to True, reloads the contents from file, disregarding the current state of the cache. 92 | encoding (str): The type of encoding to use when loading the resource. 93 | 94 | Returns: 95 | dict[Any]: The JSON data formatted as a dictionary. 96 | """ 97 | if reset or filename not in cls._json_data: 98 | raw_data = cls.load_raw(filename=filename, cache=False, encoding=encoding) 99 | try: 100 | data = json.loads(raw_data) 101 | except json.decoder.JSONDecodeError as e: 102 | message = f"Class `ResourceLoader` unable to interpret resource as JSON: `{filename}`. {e.args[0]}" 103 | raise json.decoder.JSONDecodeError(message) 104 | 105 | if cache: 106 | cls._json_data[filename] = data 107 | else: 108 | data = cls._json_data[filename] 109 | 110 | return data 111 | 112 | @classmethod 113 | def load_csv( 114 | cls, 115 | filename: str, 116 | cache: bool = True, 117 | reset: bool = False, 118 | encoding: str = "utf-8", 119 | delimiter: str = ",", 120 | quotechar: str = '"', 121 | dialect: str = "excel", 122 | strict: bool = True, 123 | ) -> list[list[str]]: 124 | """ 125 | Load a specified CSV file by filename and return its contents as a nested list of strings. 126 | 127 | Args: 128 | filename (str): The name of the resource file — should be a CSV file. 129 | cache (bool): If True, cache the loaded data to reduce overhead the next time its loaded. 130 | reset (bool): If set to True, reloads the contents from file, disregarding the current state of the cache. 131 | encoding (str): The type of encoding to use when loading the resource. 132 | delimiter (str): The CSV delimiter character. 133 | quotechar (str): The CSV quote character. 134 | dialect (str): The CSV dialect. 135 | strict (bool): If True, raises an exception when the file is not correctly formatted. 136 | 137 | Returns: 138 | list[list[str]]: The CSV data formatted as a nested list of strings. 139 | """ 140 | if reset or filename not in cls._csv_data: 141 | raw_data = cls.load_raw(filename=filename, cache=False, encoding=encoding) 142 | try: 143 | data = [] 144 | with StringIO(raw_data, newline="") as fs: 145 | reader = csv.reader(fs, delimiter=delimiter, quotechar=quotechar, dialect=dialect, strict=strict) 146 | for row in reader: 147 | data.append(row) 148 | except csv.Error as e: 149 | message = f"Class `ResourceLoader` unable to interpret resource as CSV: `{filename}`, {e.args[0]}" 150 | raise csv.Error(message) 151 | 152 | if cache: 153 | cls._csv_data[filename] = data 154 | else: 155 | data = cls._csv_data[filename] 156 | 157 | return data 158 | -------------------------------------------------------------------------------- /banterbot/models/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.models.azure_neural_voice_profile import AzureNeuralVoiceProfile 2 | from banterbot.models.memory import Memory 3 | from banterbot.models.message import Message 4 | from banterbot.models.number import Number 5 | from banterbot.models.openai_model import OpenAIModel 6 | from banterbot.models.phrase import Phrase 7 | from banterbot.models.speech_recognition_input import SpeechRecognitionInput 8 | from banterbot.models.stream_log_entry import StreamLogEntry 9 | from banterbot.models.word import Word 10 | 11 | __all__ = [ 12 | "AzureNeuralVoiceProfile", 13 | "Memory", 14 | "Message", 15 | "Number", 16 | "OpenAIModel", 17 | "Phrase", 18 | "SpeechRecognitionInput", 19 | "StreamLogEntry", 20 | "Word", 21 | ] 22 | -------------------------------------------------------------------------------- /banterbot/models/azure_neural_voice_profile.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from azure.cognitiveservices.speech import SynthesisVoiceGender 5 | 6 | 7 | @dataclass 8 | class AzureNeuralVoiceProfile: 9 | """ 10 | A dataclass representing an Azure Neural Voice profile for speech synthesis. 11 | 12 | Attributes: 13 | country (str): The country where the voice is commonly used. 14 | description (str): A brief description of the voice profile. 15 | gender (SynthesisVoiceGender): The gender of the voice. 16 | language (str): The language of the voice. 17 | locale (str): The name of the language's locale (i.e., language-country[-region]). 18 | name (str): The name of the voice profile. 19 | region (str): The region where the voice is available or commonly used. 20 | short_name (str): The voice identifier used by Azure Text-to-Speech API. 21 | style_list (list[str]): The available styles (i.e., tones/emotions) for the voice. 22 | """ 23 | 24 | country: str 25 | description: str 26 | gender: SynthesisVoiceGender 27 | language: str 28 | locale: str 29 | name: str 30 | short_name: str 31 | style_list: list[str] 32 | region: Optional[str] = None 33 | 34 | def __post_init__(self): 35 | if len(self.style_list) == 1 and not self.style_list[0].strip(): 36 | self.style_list = [] 37 | 38 | def __str__(self): 39 | styles = f"{', '.join(self.style_list)}" if self.style_list else "None" 40 | return ( 41 | f"Azure Neural Voice Profile - {self.name}:\n" 42 | f"Locale: {self.locale}\n" 43 | f"Gender: {self.gender.name}\n" 44 | f"Styles: {styles}" 45 | ) 46 | 47 | def __repr__(self): 48 | return ( 49 | f"AzureNeuralVoice(country={self.country!r}, description={self.description!r}, " 50 | f"gender={self.gender!r}, language={self.language!r}, " 51 | f"locale={self.locale!r}, name={self.name!r}, region={self.region!r}, " 52 | f"short_name={self.short_name!r}, style_list={self.style_list!r})" 53 | ) 54 | -------------------------------------------------------------------------------- /banterbot/models/memory.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass 3 | from typing import Optional 4 | 5 | # File `memory_pb2.py` is automatically generated from protoc 6 | from typing_extensions import Self 7 | 8 | from banterbot import config 9 | from banterbot.models.message import Message 10 | from banterbot.protos import memory_pb2 11 | 12 | 13 | @dataclass 14 | class Memory: 15 | """ 16 | This class represents a single memory of a persona in the form of a dataclass. A memory is defined by keywords, a 17 | summary, an impact score, a timestamp, and associated messages. 18 | 19 | Args: 20 | keywords (list[str]): The list of keywords that summarize the memory. 21 | summary (str): A brief summary of the memory. 22 | impact (float): A score to indicate the impact of the memory on the persona (accepts values 1 to 100). 23 | timestamp (datetime.datetime): The time when the memory occurred. 24 | messages (list[Message]): The list of messages associated with the memory. 25 | """ 26 | 27 | keywords: list[str] 28 | summary: str 29 | impact: int 30 | timestamp: datetime.datetime 31 | messages: list[Message] 32 | uuid: Optional[str] = None 33 | 34 | def __post_init__(self): 35 | """ 36 | Initializes a UUID for the current instance if one is not provided andtruncates microseconds from the provided 37 | timestamp. 38 | """ 39 | if self.uuid is None: 40 | self.uuid = config.generate_uuid().hex 41 | self.timestamp = self.timestamp.replace(microsecond=0) 42 | 43 | def __eq__(self, memory: "Memory"): 44 | """ 45 | Equality magic method, to allow equality checks between different instances of class `Memory` with the same 46 | contents. 47 | 48 | Args: 49 | memory (Memory): An instance of class Memory. 50 | """ 51 | return ( 52 | self.keywords == memory.keywords 53 | and self.summary == memory.summary 54 | and self.impact == memory.impact 55 | and self.timestamp == memory.timestamp 56 | and self.messages == memory.messages 57 | and self.uuid == memory.uuid 58 | ) 59 | 60 | def to_protobuf(self) -> memory_pb2.Memory: 61 | """ 62 | Converts this `Memory` instance into a protobuf object. 63 | Args: 64 | self: The instance of the `Memory` class. 65 | 66 | Returns: 67 | memory_pb2.Memory: The protobuf object equivalent of the `Memory` instance. 68 | """ 69 | memory = memory_pb2.Memory() 70 | memory.keywords.extend(self.keywords) 71 | memory.summary = self.summary 72 | memory.impact = self.impact 73 | memory.timestamp = int(self.timestamp.timestamp()) 74 | memory.messages.extend([message.to_protobuf() for message in self.messages]) 75 | memory.uuid = self.uuid 76 | 77 | return memory 78 | 79 | def serialize(self) -> str: 80 | """ 81 | Returns a serialized bytes string version of the current `Memory` instance. 82 | 83 | Returns: 84 | str: A string containing binary bytes. 85 | """ 86 | return self.to_protobuf().SerializeToString() 87 | 88 | @classmethod 89 | def deserialize(cls, data: str) -> Self: 90 | """ 91 | Constructs a `Memory` instance from a serialized string of binary bytes. 92 | 93 | Returns: 94 | Memory: The constructed `Memory` instance. 95 | """ 96 | memory = memory_pb2.Memory() 97 | memory.ParseFromString(data) 98 | return cls.from_protobuf(memory=memory) 99 | 100 | @classmethod 101 | def from_protobuf(cls, memory: memory_pb2.Memory) -> "Memory": 102 | """ 103 | Constructs a `Memory` instance from a protobuf object. 104 | 105 | Args: 106 | memory (memory_pb2.Memory): The protobuf object to convert. 107 | 108 | Returns: 109 | Memory: The constructed `Memory` instance. 110 | """ 111 | # The `Memory` instance is created with the same attributes as the protobuf object. 112 | return cls( 113 | keywords=memory.keywords, 114 | summary=memory.summary, 115 | impact=memory.impact, 116 | timestamp=datetime.datetime.fromtimestamp(memory.timestamp), 117 | messages=[Message.from_protobuf(message_proto) for message_proto in memory.messages], 118 | uuid=memory.uuid, 119 | ) 120 | -------------------------------------------------------------------------------- /banterbot/models/message.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import Optional 3 | 4 | from typing_extensions import Self 5 | 6 | from banterbot.data.enums import ChatCompletionRoles 7 | from banterbot.models.openai_model import OpenAIModel 8 | 9 | # File `memory_pb2.py` is automatically generated from protoc 10 | from banterbot.protos import memory_pb2 11 | 12 | 13 | @dataclass 14 | class Message: 15 | """ 16 | Represents a message that can be sent to the OpenAI ChatCompletion API. 17 | 18 | The purpose of this class is to create a structured representation of a message that can be easily converted into a 19 | format compatible with the OpenAI API. This class is designed to be used in conjunction with the OpenAI 20 | ChatCompletion API to generate context-aware responses from an AI model. 21 | 22 | Attributes: 23 | role (ChatCompletionRoles): The role of the message sender. 24 | - ASSISTANT: Represents a message sent by the AI assistant. 25 | - SYSTEM: Represents a message sent by the system, usually containing instructions or context. 26 | - USER: Represents a message sent by the user interacting with the AI assistant. 27 | 28 | content (str): The content of the message. 29 | 30 | name (Optional[str]): The name of the message sender. This is an optional field and can be used to provide a 31 | more personalized experience by addressing the sender by their name. 32 | """ 33 | 34 | role: ChatCompletionRoles 35 | content: str 36 | name: Optional[str] = None 37 | 38 | def to_protobuf(self) -> memory_pb2.Message: 39 | """ 40 | Converts this Message instance into a protobuf object. 41 | 42 | Args: 43 | self: The instance of the Message class. 44 | 45 | Returns: 46 | memory_pb2.Message: The protobuf object equivalent of the Message instance. 47 | """ 48 | return memory_pb2.Message( 49 | role=self.role.name, 50 | content=self.content, 51 | name=self.name, 52 | ) 53 | 54 | @classmethod 55 | def from_protobuf(cls, message_proto: memory_pb2.Message) -> Self: 56 | """ 57 | Constructs a Message instance from a protobuf object. 58 | 59 | Args: 60 | message_proto (memory_pb2.Message): The protobuf object to convert. 61 | 62 | Returns: 63 | Message: The constructed Message instance. 64 | """ 65 | # The Message instance is created with the same attributes as the protobuf object. 66 | # Note: The name is set to None if it's an empty string in the protobuf object. 67 | return cls( 68 | role=ChatCompletionRoles[message_proto.role], 69 | content=message_proto.content, 70 | name=message_proto.name if len(message_proto.name) > 0 else None, 71 | ) 72 | 73 | def __eq__(self, message: "Message"): 74 | """ 75 | Equality magic method, to allow equality checks between different instances of class Message with the same 76 | contents. 77 | 78 | Args: 79 | message (Message): An instance of class Message. 80 | """ 81 | return self.role == message.role and self.content == message.content and self.name == message.name 82 | 83 | def count_tokens(self, model: OpenAIModel) -> int: 84 | """ 85 | Counts the number of tokens in the current message. 86 | 87 | This method is useful for keeping track of the total number of tokens used in a conversation, as the OpenAI API 88 | has a maximum token limit per request. By counting tokens, you can ensure that your conversation stays within 89 | the API's token limit. 90 | 91 | Args: 92 | model (OpenAIModel): The model for which the tokenizer should count tokens. This is an instance of the 93 | OpenAIModel class, which contains the tokenizer and other model-specific information. 94 | 95 | Returns: 96 | int: The number of tokens in the specified messages. Please note that this count includes tokens for message 97 | metadata and may vary based on the specific tokenizer used by the model. 98 | """ 99 | # Add 4 tokens to account for message metadata 100 | num_tokens = 4 101 | # Count the number of tokens in the role string, ensuring role is converted to a string 102 | num_tokens += len(model.tokenizer.encode(self.role.value)) 103 | # Count the number of tokens in the content string 104 | num_tokens += len(model.tokenizer.encode(self.content)) 105 | # Count the number of tokens in the name string, if a name is provided 106 | if self.name is not None: 107 | num_tokens += len(model.tokenizer.encode(self.name)) - 1 108 | 109 | return num_tokens 110 | 111 | def __call__(self) -> dict[str, str]: 112 | """ 113 | Creates and returns a dictionary that is compatible with the OpenAI ChatCompletion API. 114 | 115 | Converts the Message object into a dictionary format that can be used as input for the OpenAI ChatCompletion 116 | API. 117 | 118 | Returns: 119 | dict[str, str]: A dictionary containing the role (converted to string), content, and optionally the name of 120 | the message sender. 121 | """ 122 | output = {"role": self.role.value, "content": self.content} 123 | if self.name is not None: 124 | output["name"] = self.name 125 | return output 126 | -------------------------------------------------------------------------------- /banterbot/models/openai_model.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | import tiktoken 4 | 5 | 6 | @dataclass 7 | class OpenAIModel: 8 | """ 9 | A class representing an OpenAI ChatCompletion model. 10 | 11 | Attributes: 12 | model (str): The name of the model. 13 | max_tokens (int): The maximum number of tokens supported by the model. 14 | generation (int): The generation number of the model (e.g., GPT-3.5=3.5 and GPT-4=4). 15 | rank (int): The model quality rank; lower values indicate higher quality responses. 16 | tokenizer (Encoding): An instance of the tiktoken package's Encoding object (i.e., a tokenizer). 17 | """ 18 | 19 | model: str 20 | max_tokens: int 21 | generation: float 22 | rank: int 23 | 24 | def __post_init__(self): 25 | """ 26 | Initializes the tokenizer attribute after the dataclass is created. 27 | 28 | The tokenizer attribute is an instance of the tiktoken package's Encoding object, which is used to tokenize text 29 | for the specific GPT model. 30 | """ 31 | self.tokenizer: tiktoken.core.Encoding = tiktoken.encoding_for_model(self.model) 32 | -------------------------------------------------------------------------------- /banterbot/models/phrase.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | from banterbot.models.azure_neural_voice_profile import AzureNeuralVoiceProfile 4 | 5 | 6 | @dataclass 7 | class Phrase: 8 | """ 9 | Contains processed data for a sub-sentence returned from a ChatCompletion ProsodySelection prompt, ready for SSML 10 | interpretation. 11 | """ 12 | 13 | text: str 14 | voice: AzureNeuralVoiceProfile 15 | style: str = "" 16 | styledegree: str = "" 17 | pitch: str = "" 18 | rate: str = "" 19 | emphasis: str = "" 20 | -------------------------------------------------------------------------------- /banterbot/models/stream_log_entry.py: -------------------------------------------------------------------------------- 1 | import time 2 | from dataclasses import dataclass 3 | from typing import Any, Optional 4 | 5 | 6 | @dataclass 7 | class StreamLogEntry: 8 | """ 9 | A dataclass used for storage of data that is yielded from a data stream in class `StreamHandler`. 10 | """ 11 | 12 | value: Any 13 | timestamp: Optional[int] = None 14 | 15 | def __post_init__(self) -> None: 16 | """ 17 | Record the time at which the entry was logged using `perf_counter_ns()`. 18 | """ 19 | self.timestamp = time.perf_counter_ns() 20 | 21 | def __str__(self) -> str: 22 | """ 23 | Return metadata about the entry. 24 | 25 | Returns: 26 | str: Metadata about the current entry. 27 | """ 28 | return self.__repr__() 29 | 30 | def __repr__(self) -> str: 31 | """ 32 | Return metadata about the entry in a more compact form. 33 | 34 | Returns: 35 | str: Metadata about the current entry. 36 | """ 37 | return f"" 38 | -------------------------------------------------------------------------------- /banterbot/models/traits/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GabrielSCabrera/BanterBot/59afb1e34eb7a87024e800d6b02899a3091c829b/banterbot/models/traits/__init__.py -------------------------------------------------------------------------------- /banterbot/models/traits/primary_trait.py: -------------------------------------------------------------------------------- 1 | import random 2 | 3 | from typing_extensions import Self 4 | 5 | from banterbot.managers.resource_manager import ResourceManager 6 | from banterbot.paths import primary_traits 7 | 8 | 9 | class PrimaryTrait: 10 | """ 11 | Primary trait loading and management, with options for random generation or specified parameters using data from the 12 | `paths.primary_traits` resource. 13 | """ 14 | 15 | def __init__(self, name: str, description: str, value: int, value_description: str): 16 | """ 17 | Initialize a PrimaryTrait instance. 18 | 19 | Args: 20 | name (str): The name of the primary trait. 21 | description (str): A textual description of the primary trait. 22 | value (int): The specific value of the primary trait. 23 | value_description (str): Description of the trait at the specific value. 24 | """ 25 | self.name = name 26 | self.description = description 27 | self.value = value 28 | self.value_description = value_description 29 | 30 | def __str__(self): 31 | return f"{self.name} (Value: {self.value}): {self.value_description}" 32 | 33 | @classmethod 34 | def load_random(cls, uuid: str) -> Self: 35 | """ 36 | Load a PrimaryTrait instance based on a UUID with a randomly selected value. 37 | 38 | Args: 39 | uuid (str): The unique identifier of the primary trait. 40 | 41 | Returns: 42 | PrimaryTrait: An instance of PrimaryTrait with a randomly selected value. 43 | """ 44 | data = cls._load_uuid(uuid) 45 | 46 | value = random.randrange(len(data["levels"])) 47 | 48 | value_description = data["levels"][value] 49 | return cls(data["name"], data["description"], value, value_description) 50 | 51 | @classmethod 52 | def load_select(cls, uuid: str, value: int) -> Self: 53 | """ 54 | Load a PrimaryTrait instance based on a UUID with a specified value. 55 | 56 | Args: 57 | uuid (str): The unique identifier of the primary trait. 58 | value (int): The specified value for the trait. 59 | 60 | Returns: 61 | PrimaryTrait: An instance of PrimaryTrait with the specified value. 62 | """ 63 | data = cls._load_uuid(uuid) 64 | 65 | if not isinstance(value, int) or 0 > value >= len(data["levels"]): 66 | raise ValueError("Specified value is out of the valid range for this trait.") 67 | 68 | value_description = data["levels"][value - data["range"][0]] 69 | return cls(data["name"], data["description"], value, value_description) 70 | 71 | @classmethod 72 | def _load_uuid(cls, uuid: str): 73 | """ 74 | Helper method to load trait data from the `primary_traits` JSON based on UUID. 75 | 76 | Args: 77 | uuid (str): The UUID of the trait. 78 | 79 | Returns: 80 | dict: The data for the specified trait. 81 | """ 82 | traits_data = ResourceManager.load_json(filename=primary_traits, cache=True, reset=False) 83 | 84 | # Search for the trait data based on UUID 85 | if uuid in traits_data["primary_traits"]: 86 | return traits_data["primary_traits"][uuid] 87 | else: 88 | message = f"The specified UUID `{uuid}` is not a known Primary Trait in `resources/{primary_traits}`" 89 | raise KeyError(message) 90 | -------------------------------------------------------------------------------- /banterbot/models/traits/secondary_trait.py: -------------------------------------------------------------------------------- 1 | import numpy as np 2 | from typing_extensions import Self 3 | 4 | from banterbot.models.traits.primary_trait import PrimaryTrait 5 | 6 | 7 | class SecondaryTrait: 8 | def __init__(self, name: str, value: np.ndarray, description: str) -> None: 9 | """ 10 | Initialize a SecondaryTrait instance. 11 | 12 | Args: 13 | name (str): The name of the secondary trait. 14 | value (np.ndarray): The numerical value of the secondary trait. 15 | description (str): A textual description of the secondary trait. 16 | """ 17 | self.name = name 18 | self.value = value 19 | self.description = description 20 | 21 | @classmethod 22 | def from_primary_traits( 23 | cls, 24 | name: str, 25 | grid: list[list[str]], 26 | primary_trait1: PrimaryTrait, 27 | primary_trait2: PrimaryTrait, 28 | cov: float = 0.95, 29 | ) -> Self: 30 | """ 31 | Create a SecondaryTrait instance based on two primary traits, using a Gaussian distribution to select a value 32 | from a provided grid. 33 | 34 | Args: 35 | name (str): The name of the secondary trait. 36 | grid (list[list[str]]): A 5x5 nested list of strings representing potential trait values. 37 | primary_trait1 (PrimaryTrait): The first primary trait influencing the secondary trait. 38 | primary_trait2 (PrimaryTrait): The second primary trait influencing the secondary trait. 39 | cov (float): The covariance for the Gaussian distribution, defaulting to 0.95. 40 | 41 | Returns: 42 | SecondaryTrait: An instance of SecondaryTrait with selected value and description. 43 | 44 | Note: 45 | The grid is expected to be 5x5, and the `cov` parameter controls the spread of the Gaussian distribution. 46 | 47 | The values of the primary traits are treated as indices for the Gaussian distribution, thus no increment is 48 | needed. 49 | 50 | The final value is adjusted (+1) to match the 1-7 range of tertiary traits in the character trait generation 51 | system. 52 | """ 53 | 54 | # Mean (mu) for the Gaussian distribution; primary_trait values become indices, so incrementing is unneeded. 55 | mu = [primary_trait1.value, primary_trait2.value] 56 | 57 | # Draw a sample from a multivariate normal distribution 58 | sample = np.random.multivariate_normal(mu, [[cov, 0], [0, cov]]) 59 | 60 | # Round the sample to the nearest integer and clip to grid limits (0 to 4) 61 | sample_rounded = np.clip(np.round(sample).astype(int), 0, 4) 62 | 63 | # Get the corresponding value from the grid 64 | selected_value = grid[sample_rounded[0]][sample_rounded[1]] 65 | 66 | return cls(name, sample_rounded + 1, selected_value) 67 | 68 | def __str__(self) -> str: 69 | return f"{self.name}: {self.value} - {self.description}" 70 | 71 | 72 | if __name__ == "__main__": 73 | 74 | def test_secondary_trait(): 75 | primary_trait1 = PrimaryTrait("A", 2) 76 | primary_trait2 = PrimaryTrait("B", 3) 77 | 78 | # Sample grid (5x5) 79 | grid = [ 80 | ["Trait1", "Trait2", "Trait3", "Trait4", "Trait5"], 81 | ["Trait6", "Trait7", "Trait8", "Trait9", "Trait10"], 82 | ["Trait11", "Trait12", "Trait13", "Trait14", "Trait15"], 83 | ["Trait16", "Trait17", "Trait18", "Trait19", "Trait20"], 84 | ["Trait21", "Trait22", "Trait23", "Trait24", "Trait25"], 85 | ] 86 | 87 | # Create a secondary trait using the class method 88 | secondary_trait = SecondaryTrait.from_primary_traits("TestTrait", grid, primary_trait1, primary_trait2) 89 | 90 | # Assertions to check if the secondary trait is within the expected range 91 | assert 1 <= secondary_trait.value[0] <= 5 92 | assert 1 <= secondary_trait.value[1] <= 5 93 | assert secondary_trait.description in [item for sublist in grid for item in sublist] 94 | 95 | print("Test Passed: SecondaryTrait created successfully with valid values and description.") 96 | 97 | print(secondary_trait) 98 | 99 | # Run the test 100 | test_secondary_trait() 101 | -------------------------------------------------------------------------------- /banterbot/models/traits/tertiary_trait.py: -------------------------------------------------------------------------------- 1 | class TertiaryTrait: 2 | def __init__(self): 3 | """ """ 4 | -------------------------------------------------------------------------------- /banterbot/models/word.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | from dataclasses import dataclass 3 | 4 | 5 | @dataclass(frozen=True) 6 | class Word: 7 | """ 8 | This class encapsulates a word in the output of a text-to-speech synthesis or input from a speech-to-text 9 | recognition. It includes the word itself and the timestamp when the word was spoken. Optionally, its category (e.g., 10 | word, punctuation using Azure's Speech Synthesis Boundary Type), and its confidence score can be included too. 11 | 12 | Attributes: 13 | word (str): The word that has been synthesized/recognized. 14 | offset (datetime.timedelta): Time elapsed between initialization and synthesis/recognition. 15 | duration (datetime.timedelta): Amount of time required for the word to be fully spoken. 16 | """ 17 | 18 | text: str 19 | offset: datetime.timedelta 20 | duration: datetime.timedelta 21 | 22 | def __len__(self) -> int: 23 | """ 24 | Computes and returns the length of the word. 25 | 26 | This method is useful for determining the length of the word without having to access the `word` attribute 27 | directly. It can be used, for example, in filtering or sorting operations on a list of `Word` 28 | instances. 29 | 30 | Returns: 31 | int: The length of the word. 32 | """ 33 | return len(self.text) 34 | 35 | def __str__(self) -> str: 36 | """ 37 | Provides a string representation of the instance, including the word and its timestamp. 38 | 39 | This method is useful for displaying a human-readable representation of the instance, which can be helpful for 40 | debugging or logging purposes. 41 | 42 | Returns: 43 | str: A string containing the word, the time elapsed since the beginning of speech synthesis, and its source. 44 | """ 45 | description = f"" 46 | return description 47 | 48 | def __repr__(self) -> str: 49 | """ 50 | Returns the word itself as its string representation. This simplifies the display of the object in certain 51 | contexts, such as when printing a list of `Word` instances. 52 | 53 | This method is called by built-in Python functions like `repr()` and is used, for example, when displaying the 54 | object in an interactive Python session or when printing a list containing the object. 55 | 56 | Returns: 57 | str: The word itself. 58 | """ 59 | return self.text 60 | -------------------------------------------------------------------------------- /banterbot/paths.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | # Initialize the filesystem for BanterBot 4 | filesystem = Path.home() / "Documents" / "BanterBot" 5 | filesystem.mkdir(parents=True, exist_ok=True) 6 | 7 | # Initialize the chat log directory 8 | chat_logs = filesystem / "Conversations" 9 | chat_logs.mkdir(parents=True, exist_ok=True) 10 | 11 | # Initialize the personae memory and personality storage 12 | personae = filesystem / "Personae" 13 | personae.mkdir(parents=True, exist_ok=True) 14 | 15 | # The name of the resource file containing OpenAI ChatCompletion models. 16 | openai_models = "openai_models.json" 17 | # The file that contains all data for primary traits. 18 | primary_traits = "primary_traits.json" 19 | 20 | # The extension that should be used in saving protocol buffers to file 21 | protobuf_extension = ".bin" 22 | # The name of the file in which to index memories by keyword 23 | memory_index = "memory_index" + protobuf_extension 24 | # The name of the directory in which memories should be saved 25 | memories = "memories" 26 | -------------------------------------------------------------------------------- /banterbot/protos/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GabrielSCabrera/BanterBot/59afb1e34eb7a87024e800d6b02899a3091c829b/banterbot/protos/__init__.py -------------------------------------------------------------------------------- /banterbot/protos/memory.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package chat; 4 | 5 | // Message structure used in the chat system. 6 | message Message { 7 | string role = 1; // Role of the sender. 8 | string content = 2; // Content of the message. 9 | string name = 3; // Optional name of the sender. 10 | } 11 | 12 | // Memory structure used for storing conversations. 13 | message Memory { 14 | repeated string keywords = 1; // Keywords associated with the memory. 15 | string summary = 2; // Summary of the memory. 16 | int64 impact = 3; // Impact score of the memory. 17 | int64 timestamp = 4; // Timestamp when the memory occurred. 18 | repeated Message messages = 5; // Messages associated with the memory. 19 | string uuid = 6; // UUID assigned to a memory. 20 | } 21 | 22 | // Memory indexing entry structure for linking keywords to memories. 23 | message MemoryIndexEntry { 24 | string keyword = 1; // A single keyword associated with one or more memories. 25 | repeated string memory_uuids = 2; // UUIDs of the memories associated with the specified keyword. 26 | } 27 | 28 | // Memory indexing structure for linking keywords to memories. 29 | message MemoryIndex { 30 | repeated MemoryIndexEntry entries = 1; // A set of MemoryIndexEntries of indeterminate length. 31 | } 32 | -------------------------------------------------------------------------------- /banterbot/protos/memory_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: memory.proto 4 | """Generated protocol buffer code.""" 5 | from google.protobuf import descriptor as _descriptor 6 | from google.protobuf import descriptor_pool as _descriptor_pool 7 | from google.protobuf import symbol_database as _symbol_database 8 | from google.protobuf.internal import builder as _builder 9 | 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | 15 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( 16 | b'\n\x0cmemory.proto\x12\x04\x63hat"6\n\x07Message\x12\x0c\n\x04role\x18\x01' 17 | b" \x01(\t\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\t\x12\x0c\n\x04name\x18\x03" 18 | b' \x01(\t"}\n\x06Memory\x12\x10\n\x08keywords\x18\x01 \x03(\t\x12\x0f\n\x07summary\x18\x02' 19 | b" \x01(\t\x12\x0e\n\x06impact\x18\x03 \x01(\x03\x12\x11\n\ttimestamp\x18\x04" 20 | b" \x01(\x03\x12\x1f\n\x08messages\x18\x05 \x03(\x0b\x32\r.chat.Message\x12\x0c\n\x04uuid\x18\x06" 21 | b' \x01(\t"9\n\x10MemoryIndexEntry\x12\x0f\n\x07keyword\x18\x01 \x01(\t\x12\x14\n\x0cmemory_uuids\x18\x02' 22 | b" \x03(\t\"6\n\x0bMemoryIndex\x12'\n\x07\x65ntries\x18\x01 \x03(\x0b\x32\x16.chat.MemoryIndexEntryb\x06proto3" 23 | ) 24 | 25 | _globals = globals() 26 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 27 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "memory_pb2", _globals) 28 | if _descriptor._USE_C_DESCRIPTORS == False: 29 | DESCRIPTOR._options = None 30 | _globals["_MESSAGE"]._serialized_start = 22 31 | _globals["_MESSAGE"]._serialized_end = 76 32 | _globals["_MEMORY"]._serialized_start = 78 33 | _globals["_MEMORY"]._serialized_end = 203 34 | _globals["_MEMORYINDEXENTRY"]._serialized_start = 205 35 | _globals["_MEMORYINDEXENTRY"]._serialized_end = 262 36 | _globals["_MEMORYINDEX"]._serialized_start = 264 37 | _globals["_MEMORYINDEX"]._serialized_end = 318 38 | # @@protoc_insertion_point(module_scope) 39 | -------------------------------------------------------------------------------- /banterbot/resources/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GabrielSCabrera/BanterBot/59afb1e34eb7a87024e800d6b02899a3091c829b/banterbot/resources/__init__.py -------------------------------------------------------------------------------- /banterbot/resources/openai_models.json: -------------------------------------------------------------------------------- 1 | { 2 | "gpt-3.5-turbo": { 3 | "model": "gpt-3.5-turbo", 4 | "max_tokens": 16383, 5 | "generation": 3.5, 6 | "rank": 6 7 | }, 8 | "gpt-4": { 9 | "model": "gpt-4", 10 | "max_tokens": 8191, 11 | "generation": 4, 12 | "rank": 5 13 | }, 14 | "gpt-4-32k": { 15 | "model": "gpt-4-32k", 16 | "max_tokens": 32767, 17 | "generation": 4, 18 | "rank": 4 19 | }, 20 | "gpt-4-turbo": { 21 | "model": "gpt-4-turbo", 22 | "max_tokens": 127999, 23 | "generation": 4.5, 24 | "rank": 3 25 | }, 26 | "gpt-4o": { 27 | "model": "gpt-4o", 28 | "max_tokens": 127999, 29 | "generation": 4.6, 30 | "rank": 1 31 | }, 32 | "gpt-4o-mini": { 33 | "model": "gpt-4o-mini", 34 | "max_tokens": 127999, 35 | "generation": 4.7, 36 | "rank": 2 37 | } 38 | } -------------------------------------------------------------------------------- /banterbot/resources/primary_traits.json: -------------------------------------------------------------------------------- 1 | { 2 | "018bf3b6-e25d-8cee-b2e8-a81f31f8c270": { 3 | "name": "Openness", 4 | "description": "Openness to experience", 5 | "levels": [ 6 | "Reserved", 7 | "Curious", 8 | "Inventive" 9 | ] 10 | }, 11 | "018bf3b6-e25d-8cee-b342-b4b39411e7bd": { 12 | "name": "Conscientiousness", 13 | "description": "Level of conscientious behavior", 14 | "levels": [ 15 | "Easygoing", 16 | "Organized", 17 | "Diligent" 18 | ] 19 | }, 20 | "018bf3b6-e25d-8cee-b3a0-63b6c6108eea": { 21 | "name": "Extraversion", 22 | "description": "Level of sociability and enthusiasm", 23 | "levels": [ 24 | "Reserved", 25 | "Sociable", 26 | "Energetic" 27 | ] 28 | }, 29 | "018bf3b6-e25d-8cee-b3d9-1346c1fea251": { 30 | "name": "Agreeableness", 31 | "description": "Level of friendliness and compassion", 32 | "levels": [ 33 | "Critical", 34 | "Cooperative", 35 | "Compassionate" 36 | ] 37 | }, 38 | "018bf3b6-e25d-8cee-b431-0e492abed59c": { 39 | "name": "Neuroticism", 40 | "description": "Tendency towards emotional instability", 41 | "levels": [ 42 | "Calm", 43 | "Sensitive", 44 | "Anxious" 45 | ] 46 | }, 47 | "018bf3b6-e25d-8cee-b460-a6bf4caf985b": { 48 | "name": "Collectivism vs Individualism", 49 | "description": "Preference for group harmony vs self-reliance", 50 | "levels": [ 51 | "Collectivist", 52 | "Balanced", 53 | "Individualist" 54 | ] 55 | }, 56 | "018bf3b6-e25d-8cee-b4b6-e51d602971bd": { 57 | "name": "Power Distance", 58 | "description": "Acceptance of hierarchical order", 59 | "levels": [ 60 | "Egalitarian", 61 | "Moderate", 62 | "Authoritarian" 63 | ] 64 | }, 65 | "018bf3b6-e25d-8cee-b4e8-2e156dc9e3b6": { 66 | "name": "Uncertainty Avoidance", 67 | "description": "Tolerance for ambiguity and uncertainty", 68 | "levels": [ 69 | "Adaptable", 70 | "Cautious", 71 | "Rigorous" 72 | ] 73 | }, 74 | "018bf3b6-e25d-8cee-b52a-5e79c03a396a": { 75 | "name": "Masculinity vs Femininity", 76 | "description": "Preference for achievement vs cooperation", 77 | "levels": [ 78 | "Competitive", 79 | "Balanced", 80 | "Nurturing" 81 | ] 82 | }, 83 | "018bf3b6-e25d-8cee-b541-9b389cd2255f": { 84 | "name": "Long-Term vs Short-Term Orientation", 85 | "description": "Focus on future rewards vs present rewards", 86 | "levels": [ 87 | "Traditional", 88 | "Balanced", 89 | "Progressive" 90 | ] 91 | }, 92 | "018bf3b6-e25d-8cee-b5b9-8c7ff4cb5ae0": { 93 | "name": "Indulgence vs Restraint", 94 | "description": "Tendency towards gratification vs control", 95 | "levels": [ 96 | "Indulgent", 97 | "Balanced", 98 | "Restrained" 99 | ] 100 | }, 101 | "018bf3b6-e25d-8cee-b5c3-a3e443b948df": { 102 | "name": "Prudishness vs Openness in Social Norms", 103 | "description": "Attitude towards social norms and taboos", 104 | "levels": [ 105 | "Conservative", 106 | "Moderate", 107 | "Liberal" 108 | ] 109 | }, 110 | "018bf3b6-e25d-8cee-b627-31ebbbfbc038": { 111 | "name": "Religious Orientation", 112 | "description": "Degree of religious influence in life", 113 | "levels": [ 114 | "Secular", 115 | "Moderate", 116 | "Devout" 117 | ] 118 | }, 119 | "018bf3b6-e25d-8cee-b67f-cb579617d8e9": { 120 | "name": "Harmony vs Competition", 121 | "description": "Preference for cooperative vs competitive interactions", 122 | "levels": [ 123 | "Harmonious", 124 | "Balanced", 125 | "Competitive" 126 | ] 127 | }, 128 | "018bf3b6-e25d-8cee-b69a-a9623c93f489": { 129 | "name": "Communication Style", 130 | "description": "Preference for high-context vs low-context communication", 131 | "levels": [ 132 | "Implicit", 133 | "Adaptive", 134 | "Explicit" 135 | ] 136 | }, 137 | "018bf3b6-e25d-8cee-b6ff-cdd81fefbb17": { 138 | "name": "Adaptability and Change", 139 | "description": "Openness to change and new experiences", 140 | "levels": [ 141 | "Stable", 142 | "Flexible", 143 | "Dynamic" 144 | ] 145 | } 146 | } -------------------------------------------------------------------------------- /banterbot/resources/traits.csv: -------------------------------------------------------------------------------- 1 | category,trait,1,2,3,4,5,6,7 2 | personality,openness,close-minded,cautious,curious,open-minded,imaginative,adventurous,inventive 3 | personality,conscientiousness,disorganized,careless,casual,conscientious,organized,diligent,meticulous 4 | personality,extraversion,introverted,reserved,quiet,ambivert,sociable,outgoing,extroverted 5 | personality,agreeableness,hostile,competitive,assertive,agreeable,friendly,compassionate,empathetic 6 | personality,neuroticism,calm,stable,composed,balanced,anxious,emotional,neurotic 7 | politics,authority,libertarian,minarchist,moderate,centrist,authoritarian,totalitarian,dictatorial 8 | politics,economic system,communist,socialist,mixed economy,centrist,capitalist,free market,laissez-faire 9 | politics,social orientation,individualist,independent,moderate,centrist,communitarian,collectivist,collectivist 10 | politics,tradition,progressive,reformist,moderate,centrist,conservative,traditionalist,reactionary 11 | politics,national identity,globalist,internationalist,moderate,centrist,nationalist,patriotic,jingoistic 12 | politics,welfare,social welfare,welfare state,moderate,centrist,mercantilist,protectionist,laissez-faire 13 | politics,military stance,pacifist,non-interventionist,moderate,centrist,militarist,hawkish,warmonger 14 | politics,foreign policy,isolationist,non-interventionist,moderate,centrist,interventionist,imperialist,expansionist 15 | politics,environment,environmentalist,green,moderate,centrist,industrialist,exploitative,polluter 16 | politics,religion & state,secular,non-religious,moderate,centrist,religious,theocratic,fundamentalist 17 | politics,equality,egalitarian,equalitarian,moderate,centrist,elitist,aristocratic,plutocratic 18 | politics,governance,decentralized,federalist,moderate,centrist,centralized,unitary,autocratic 19 | politics,economic policy,interventionist,keynesian,moderate,centrist,laissez-faire,free market,anarcho-capitalist 20 | politics,civil liberties,civil libertarian,privacy advocate,moderate,centrist,security-focused,authoritarian,totalitarian 21 | ideology,belief in god,atheist,skeptic,agnostic,deist,theist,devout,zealous 22 | ideology,spirituality,materialistic,pragmatic,realist,balanced,spiritual,mystical,transcendent 23 | ideology,social focus,individualistic,independent,moderate,centrist,communal,collectivist,communitarian 24 | ideology,certainty,agnostic,skeptic,questioning,open-minded,dogmatic,unwavering,fanatical 25 | ideology,religious practice,informal,casual,flexible,balanced,ritualistic,ceremonial,orthodox 26 | ideology,knowledge source,scientific,rational,empirical,balanced,supernatural,mystical,esoteric 27 | ideology,moral perspective,absolutist,principled,discerning,balanced,relativist,situational,subjectivist 28 | ideology,worldview,mundane,realist,grounded,balanced,mystic,enchanted,magical 29 | ideology,religious imagery,iconoclastic,reformer,moderate,balanced,idolatrous,reverent,idolizer 30 | ideology,faith sharing,private,reserved,discreet,balanced,proselytist,evangelical,missionary 31 | ideology,outlook,pessimistic,cynical,realist,balanced,optimistic,hopeful,utopian 32 | ideology,existential stance,nihilistic,skeptical,questioning,balanced,existentialist,purposeful,meaningful 33 | ideology,pleasure seeking,hedonistic,sensualist,moderate,balanced,ascetic,disciplined,self-denying 34 | ideology,view of humanity,misanthropic,cynical,skeptical,balanced,humanistic,compassionate,altruistic 35 | expertise,artistic,inept,amateur,apprentice,competent,skilled,accomplished,masterful 36 | expertise,scientific,uninformed,basic,familiar,proficient,knowledgeable,expert,renowned 37 | expertise,technological,clueless,novice,capable,adept,advanced,specialist,innovator 38 | expertise,social,awkward,reserved,cordial,sociable,charismatic,influential,magnetic 39 | expertise,practical,incompetent,rudimentary,functional,handy,resourceful,adaptable,ingenious 40 | expertise,historical,unaware,elementary,informed,well-versed,scholarly,historian,authority 41 | expertise,cultural,ignorant,basic,aware,cultured,refined,connoisseur,savant 42 | expertise,linguistic,inarticulate,limited,conversational,fluent,bilingual,polyglot,linguist 43 | expertise,philosophical,unreflective,curious,thoughtful,analytical,wise,philosopher,visionary 44 | expertise,leadership,ineffective,follower,collaborative,motivator,inspiring,leader,trailblazer 45 | expertise,financial,unskilled,budgeter,planner,manager,investor,wealth-builder,tycoon 46 | expertise,medical,untrained,first-aider,caregiver,practitioner,healer,physician,specialist 47 | expertise,military,unprepared,recruit,soldier,tactician,strategist,commander,general 48 | expertise,psychological,unperceptive,observant,insightful,empathetic,intuitive,psychologist,mind-reader 49 | expertise,aesthetic,unrefined,basic,appreciative,discerning,tasteful,stylist,trendsetter 50 | expertise,legal,uninformed,layman,paralegal,lawyer,advocate,jurist,legal scholar 51 | expertise,educational,unqualified,tutor,instructor,educator,mentor,pedagogue,master teacher 52 | expertise,athletic,uncoordinated,beginner,active,fit,athlete,elite,champion 53 | mental,memory,forgetful,inconsistent,average,reliable,sharp,exceptional,photographic 54 | mental,empathy,detached,distant,sympathetic,empathetic,compassionate,intuitive,profound 55 | mental,logic,illogical,simple,reasonable,logical,analytical,rigorous,impeccable 56 | mental,intuition,oblivious,hesitant,sensitive,intuitive,perceptive,insightful,clairvoyant 57 | mental,persuasion,unconvincing,tentative,persuasive,influential,compelling,captivating,irresistible 58 | mental,focus,distracted,inattentive,focused,attentive,sharp,unwavering,laser-focused 59 | mental,creativity,unimaginative,conventional,creative,inventive,original,visionary,revolutionary 60 | mental,wittiness,dull,dry,amusing,witty,clever,quick-witted,razor-sharp 61 | mental,problem solving,ineffective,basic,competent,skilled,resourceful,expert,master solver 62 | mental,critical thinking,naive,simplistic,analytical,critical,discerning,astute,incisive 63 | mental,learning ability,slow,steady,adaptable,quick,eager,adept,prodigious 64 | mental,adaptability,rigid,inflexible,adaptable,flexible,resilient,versatile,chameleon-like 65 | physical,strength,weak,feeble,average,strong,powerful,herculean,unstoppable 66 | physical,endurance,fragile,delicate,moderate,resilient,persistent,tireless,indefatigable 67 | physical,agility,clumsy,awkward,nimble,agile,graceful,acrobatic,lightning-fast 68 | physical,coordination,uncoordinated,fumbling,coordinated,skilled,precise,fluid,masterful 69 | physical,perception,oblivious,unobservant,aware,perceptive,sharp,eagle-eyed,omnipresent 70 | physical,seduction,unappealing,plain,charming,attractive,alluring,irresistible,enchanting 71 | physical,flexibility,stiff,inflexible,flexible,limber,supple,elastic,contortionist 72 | physical,stamina,exhausted,weary,steady,energetic,vigorous,dynamic,boundless 73 | physical,speed,slow,leisurely,average,swift,rapid,blazing,meteoric 74 | physical,balance,unsteady,wobbly,balanced,stable,poised,centered,unshakable 75 | physical,pain tolerance,sensitive,delicate,tolerant,resilient,stoic,unflinching,impervious 76 | relationships,trustworthiness,unreliable,inconsistent,trustworthy,dependable,loyal,steadfast,unwavering 77 | relationships,loyalty,disloyal,uncommitted,loyal,devoted,faithful,unfaltering,unconditional 78 | relationships,altruism,selfish,indifferent,considerate,altruistic,generous,selfless,saintly 79 | relationships,assertiveness,passive,hesitant,assertive,confident,bold,commanding,dominant 80 | relationships,conflict resolution,combative,avoidant,diplomatic,mediator,peacemaker,harmonizer,reconciler 81 | relationships,communication skills,inarticulate,reserved,articulate,communicative,persuasive,eloquent,charismatic 82 | relationships,emotional intelligence,unaware,insensitive,aware,empathetic,intuitive,insightful,enlightened 83 | relationships,social awareness,oblivious,unobservant,aware,attuned,perceptive,astute,savvy 84 | relationships,supportiveness,unsupportive,indifferent,supportive,encouraging,nurturing,empowering,inspirational 85 | relationships,adaptability,rigid,inflexible,adaptable,flexible,resilient,versatile,chameleon-like 86 | relationships,empathy,detached,distant,sympathetic,empathetic,compassionate,intuitive,profound 87 | relationships,humor,dull,dry,amusing,witty,clever,quick-witted,razor-sharp 88 | relationships,patience,impatient,restless,patient,tolerant,enduring,unruffled,unshakable 89 | relationships,tactfulness,tactless,insensitive,diplomatic,tactful,discreet,gracious,suave 90 | values,honesty,deceptive,dishonest,sincere,honest,trustworthy,transparent,incorruptible 91 | values,integrity,unprincipled,compromising,principled,ethical,honorable,virtuous,impeccable 92 | values,responsibility,irresponsible,negligent,accountable,responsible,dependable,reliable,unfailing 93 | values,compassion,cold,indifferent,sympathetic,compassionate,warmhearted,empathetic,boundless 94 | values,fairness,unfair,biased,equitable,fair,just,impartial,unbiased 95 | values,respect,disrespectful,dismissive,polite,respectful,reverent,admiring,venerating 96 | values,humility,arrogant,haughty,modest,humble,unassuming,self-effacing,egoless 97 | values,courage,cowardly,fearful,brave,courageous,valiant,fearless,unstoppable 98 | values,perseverance,quitter,inconsistent,persistent,persevering,tenacious,resolute,unyielding 99 | values,gratitude,ungrateful,unappreciative,thankful,grateful,appreciative,content,overflowing 100 | values,generosity,stingy,selfish,giving,generous,magnanimous,charitable,philanthropic 101 | values,forgiveness,vengeful,bitter,forgiving,merciful,compassionate,pardoning,unconditional 102 | values,tolerance,intolerant,judgmental,accepting,tolerant,open-minded,embracing,all-encompassing 103 | values,discipline,impulsive,indulgent,disciplined,self-controlled,composed,steadfast,unwavering 104 | -------------------------------------------------------------------------------- /banterbot/services/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.services.openai_service import OpenAIService 2 | from banterbot.services.speech_recognition_service import SpeechRecognitionService 3 | from banterbot.services.speech_synthesis_service import SpeechSynthesisService 4 | 5 | __all__ = ["OpenAIService", "SpeechRecognitionService", "SpeechSynthesisService"] 6 | -------------------------------------------------------------------------------- /banterbot/services/openai_service.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import threading 5 | import time 6 | from typing import Iterator, Optional, Union 7 | 8 | import openai 9 | 10 | from banterbot.config import RETRY_LIMIT, RETRY_TIME 11 | from banterbot.data.enums import EnvVar 12 | from banterbot.handlers.stream_handler import StreamHandler 13 | from banterbot.managers.stream_manager import StreamManager 14 | from banterbot.models.message import Message 15 | from banterbot.models.openai_model import OpenAIModel 16 | from banterbot.models.stream_log_entry import StreamLogEntry 17 | from banterbot.utils.nlp import NLP 18 | 19 | 20 | class OpenAIService: 21 | """ 22 | A class that handles the interaction with the OpenAI ChatCompletion API. It provides functionality to generate 23 | responses from the API based on the input messages. It supports generating responses as a whole or as a stream of 24 | response blocks. 25 | 26 | The main purpose of this class is to facilitate the communication with the OpenAI API and handle the responses 27 | generated by the API. It can be used to create chatbots or other applications that require natural language 28 | processing and generation. 29 | """ 30 | 31 | api_key_set = False 32 | client = None 33 | 34 | def __init__(self, model: OpenAIModel) -> None: 35 | """ 36 | Initializes an `OpenAIService` instance for a specific model. 37 | 38 | Args: 39 | model (OpenAIModel): The OpenAI model to be used. This should be an instance of the OpenAIModel class, which 40 | contains information about the model, such as its name and maximum token limit. 41 | """ 42 | logging.debug(f"OpenAIService initialized") 43 | 44 | # Set the OpenAI API key 45 | if not self.__class__.api_key_set: 46 | api_key = os.environ.get(EnvVar.OPENAI_API_KEY.value) 47 | self.__class__.client = openai.OpenAI(api_key=api_key) 48 | self.__class__.api_key_set = True 49 | 50 | # The selected model that will be used in OpenAI ChatCompletion prompts. 51 | self._model = model 52 | 53 | # Indicates whether the current instance of `OpenAIService` is streaming. 54 | self._streaming = False 55 | 56 | # Set the interruption flag to zero: if interruptions are raised, this will be updated. 57 | self._interrupt = 0 58 | 59 | # Initialize the StreamManager for handling streaming processes. 60 | self._stream_manager = StreamManager() 61 | self._stream_manager.connect_processor(self._processor) 62 | self._stream_manager.connect_completion_handler(self._completion_handler) 63 | 64 | # A list of active stream handlers. 65 | self._stream_handlers = [] 66 | self._stream_handlers_lock = threading.Lock() 67 | 68 | def interrupt(self, kill: bool = False) -> None: 69 | """ 70 | Interrupts the current OpenAI ChatCompletion process. 71 | 72 | Args: 73 | kill (bool): Whether the interruption should kill the queues or not. 74 | """ 75 | self._interrupt = time.perf_counter_ns() 76 | with self._stream_handlers_lock: 77 | for handler in self._stream_handlers: 78 | handler.interrupt(kill=kill) 79 | self._stream_handlers.clear() 80 | logging.debug(f"OpenAIService Interrupted") 81 | 82 | def count_tokens(self, string: str) -> int: 83 | """ 84 | Counts the number of tokens in the provided string. 85 | 86 | Args: 87 | string (str): A string provided by the user where the number of tokens are to be counted. 88 | 89 | Returns: 90 | int: The number of tokens in the string. 91 | """ 92 | return len(self._model.tokenizer.encode(string)) 93 | 94 | def prompt(self, messages: list[Message], split: bool = True, **kwargs) -> Union[tuple[str], str]: 95 | """ 96 | Sends messages to the OpenAI ChatCompletion API and retrieves the response as a list of sentences. 97 | 98 | Args: 99 | messages (list[Message]): A list of messages. Each message should be an instance of the `Message` class, 100 | which contains the content and role (user or assistant) of the message. 101 | 102 | split (bool): Whether the response should be split into sentences. 103 | 104 | **kwargs: Additional parameters for the API request. These can include settings such as temperature, top_p, 105 | and frequency_penalty. 106 | 107 | Returns: 108 | Union[list[str], str]: A list of sentences forming the response from the OpenAI API. This can be used to 109 | display the generated response to the user or for further processing. If `split` is False, returns a string. 110 | """ 111 | response = self._request(messages=messages, stream=False, **kwargs) 112 | logging.debug(f"OpenAIService stream processed block: `{response}`") 113 | sentences = NLP.segment_sentences(response) if split else response 114 | return sentences 115 | 116 | def prompt_stream( 117 | self, messages: list[Message], init_time: Optional[int] = None, **kwargs 118 | ) -> Union[StreamHandler, tuple[()]]: 119 | """ 120 | Sends messages to the OpenAI API and retrieves the response as a stream of blocks of sentences. 121 | 122 | Args: 123 | messages (list[Message]): A list of messages. Each message should be an instance of the `Message` class, 124 | which contains the content and role (user or assistant) of the message. 125 | 126 | init_time (Optional[int]): The time at which the stream was initialized. 127 | 128 | **kwargs: Additional parameters for the API request. These can include settings such as temperature, top_p, 129 | and frequency_penalty. 130 | 131 | Returns: 132 | Union[StreamHandler, tuple[()]]: A handler for the stream of blocks of sentences forming the response from 133 | the OpenAI API or an empty tuple if the stream was interrupted. 134 | """ 135 | # Record the time at which the stream was initialized pre-lock, in order to account for future interruptions. 136 | init_time = time.perf_counter_ns() if init_time is None else init_time 137 | 138 | if self._interrupt >= init_time: 139 | return tuple() 140 | else: 141 | # Obtain a response from the OpenAI ChatCompletion API 142 | stream = self._request(messages=messages, stream=True, **kwargs) 143 | handler = self._stream_manager.stream( 144 | iterable=stream, 145 | # close_stream=stream.response.close, 146 | init_shared_data={"text": "", "sentences": [], "init_time": init_time}, 147 | ) 148 | with self._stream_handlers_lock: 149 | self._stream_handlers.append(handler) 150 | 151 | return handler 152 | 153 | @property 154 | def model(self) -> OpenAIModel: 155 | """ 156 | Return the `OpenAIModel` associated with the current instance. 157 | 158 | Returns: 159 | OpenAIModel 160 | """ 161 | return self._model 162 | 163 | def _processor(self, log: list[StreamLogEntry], index: int, shared_data: dict) -> list[str]: 164 | """ 165 | Parses a chunk of data from the OpenAI API response. 166 | 167 | Args: 168 | log (list[StreamLogEntry]): A list of `StreamLogEntry` instances containing the data from the OpenAI API 169 | response. 170 | index (int): The index of the current chunk of data. 171 | shared_data (dict): A dictionary containing shared data between the stream handler and the processor. 172 | 173 | Returns: 174 | list[str]: A list of sentences parsed from the chunk. 175 | """ 176 | if shared_data["interrupt"] >= shared_data["init_time"]: 177 | raise StopIteration 178 | else: 179 | if log[index].value.choices[0].delta.content is not None: 180 | shared_data["text"] += log[index].value.choices[0].delta.content 181 | 182 | shared_data["sentences"] = NLP.segment_sentences(shared_data["text"]) 183 | 184 | # If the current chunk is not the final chunk of data from the OpenAI API response, parse the chunk. 185 | if len(shared_data["sentences"]) > 1: 186 | shared_data["text"] = shared_data["sentences"][-1] 187 | logging.debug(f"OpenAIService yielded sentences: {shared_data['sentences'][:-1]}") 188 | return shared_data["sentences"][:-1] 189 | 190 | def _completion_handler(self, log: list[StreamLogEntry], shared_data: dict) -> list[str]: 191 | """ 192 | Handles the completion of the OpenAI API response. 193 | 194 | Args: 195 | log (list[StreamLogEntry]): A list of `StreamLogEntry` instances containing the data from the OpenAI API 196 | response. 197 | shared_data (dict): A dictionary containing shared data between the stream handler and the processor. 198 | 199 | Returns: 200 | list[str]: A list of sentences parsed from the chunk. 201 | """ 202 | if shared_data["interrupt"] >= shared_data["init_time"]: 203 | raise StopIteration 204 | else: 205 | # If the current chunk is the final chunk of data from the OpenAI API response, parse the final chunk. 206 | shared_data["sentences"] = NLP.segment_sentences(shared_data["text"]) 207 | logging.debug(f"OpenAIService yielded final sentences: {shared_data['sentences'][:-1]}") 208 | logging.debug("OpenAIService stream stopped") 209 | return shared_data["sentences"] 210 | 211 | def _request(self, messages: list[Message], stream: bool, **kwargs) -> Union[Iterator, str]: 212 | """ 213 | Sends a request to the OpenAI API and generates a response based on the specified parameters. 214 | 215 | Args: 216 | messages (list[Message]): A list of messages. Each message should be an instance of the `Message` class, 217 | which contains the content and role (user or assistant) of the message. 218 | 219 | stream (bool): Whether the response should be returned as an iterable stream or a complete text. 220 | 221 | **kwargs: Additional parameters for the API request. These can include settings such as temperature, top_p, 222 | and frequency_penalty. 223 | 224 | Returns: 225 | Union[Iterator, str]: The stream from the OpenAI API, either as a stream (Iterator) or text (str). 226 | """ 227 | kwargs["model"] = self._model.model 228 | kwargs["n"] = 1 229 | kwargs["stream"] = stream 230 | kwargs["messages"] = [message() for message in messages] 231 | success = False 232 | for i in range(RETRY_LIMIT): 233 | try: 234 | response = self.__class__.client.chat.completions.create(**kwargs) 235 | success = True 236 | break 237 | 238 | except openai.RateLimitError: 239 | retry_timestamp = datetime.datetime.now() + datetime.timedelta(seconds=RETRY_TIME) 240 | retry_timestamp = datetime.datetime.strftime(retry_timestamp, "%H:%M:%S") 241 | error_message = ( 242 | f"OpenAIService encountered an OpenAI Rate Limiting Error - Attempt {i+1}/{RETRY_LIMIT}." 243 | f" Waiting {RETRY_TIME} seconds until {retry_timestamp} to retry." 244 | ) 245 | logging.info(error_message) 246 | time.sleep(RETRY_TIME) 247 | 248 | except openai.APIError: 249 | retry_time = 0.25 250 | retry_timestamp = datetime.datetime.now() + datetime.timedelta(seconds=retry_time) 251 | retry_timestamp = retry_timestamp.strftime("%H:%M:%S") 252 | error_message = ( 253 | f"OpenAIService encountered an OpenAI API Error - Attempt {i+1}/{RETRY_LIMIT}. Waiting " 254 | f"{retry_time} seconds until {retry_timestamp} to retry." 255 | ) 256 | logging.info(error_message) 257 | time.sleep(retry_time) 258 | 259 | if not success: 260 | raise RuntimeError(f"OpenAIService encountered too many OpenAI API Errors; exiting program.") 261 | 262 | return response if stream else response.choices[0].message.content.strip() 263 | -------------------------------------------------------------------------------- /banterbot/services/speech_synthesis_service.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | import os 4 | import threading 5 | import time 6 | from collections.abc import Generator 7 | from typing import Optional 8 | 9 | import azure.cognitiveservices.speech as speechsdk 10 | import numba as nb 11 | from azure.cognitiveservices.speech import SpeechSynthesisOutputFormat 12 | 13 | from banterbot.data.enums import EnvVar 14 | from banterbot.handlers.speech_synthesis_handler import SpeechSynthesisHandler 15 | from banterbot.models.phrase import Phrase 16 | from banterbot.models.word import Word 17 | from banterbot.utils.closeable_queue import CloseableQueue 18 | 19 | 20 | class SpeechSynthesisService: 21 | """ 22 | The `SpeechSynthesisService` class provides an interface to convert text into speech using Azure's Cognitive 23 | Services. It supports various output formats, voices, and speaking styles. The synthesized speech can be 24 | interrupted, and the progress can be monitored in real-time. 25 | """ 26 | 27 | # Create a lock that prevents race conditions when synthesizing speech 28 | _synthesis_lock = threading.Lock() 29 | 30 | def __init__( 31 | self, 32 | output_format: SpeechSynthesisOutputFormat = SpeechSynthesisOutputFormat.Audio16Khz32KBitRateMonoMp3, 33 | ) -> None: 34 | """ 35 | Initializes an instance of the `SpeechSynthesisService` class with a specified output format. 36 | 37 | Args: 38 | output_format (SpeechSynthesisOutputFormat, optional): The desired output format for the synthesized speech. 39 | Default is Audio16Khz32KBitRateMonoMp3. 40 | """ 41 | # Initialize the output format 42 | self._output_format = output_format 43 | 44 | # Initialize the speech synthesizer with the specified output format 45 | self._init_synthesizer(output_format=self._output_format) 46 | 47 | # Initialize the queue for storing the words as they are synthesized 48 | self._queue = CloseableQueue() 49 | 50 | # The iterable that is currently being iterated over 51 | self._iterable: Optional[SpeechSynthesisHandler] = None 52 | 53 | # The latest interruption time. 54 | self._interrupt = 0 55 | 56 | def interrupt(self) -> None: 57 | """ 58 | Interrupts the current speech synthesis process. 59 | 60 | Args: 61 | kill (bool): Whether the interruption should kill the queues or not. 62 | """ 63 | self._interrupt = time.perf_counter_ns() 64 | self._queue.close() 65 | # Closing the connection to the speech synthesizer. 66 | self._connection.close() 67 | # Reinitialize the speech synthesizer with the default output format 68 | self._init_synthesizer(output_format=self._output_format) 69 | logging.debug(f"SpeechSynthesisService Interrupted") 70 | 71 | def synthesize(self, phrases: list[Phrase], init_time: Optional[int] = None) -> Generator[Word, None, None]: 72 | """ 73 | Synthesizes the given phrases into speech and returns a handler for the stream of synthesized words. 74 | 75 | Args: 76 | phrases (list[Phrase]): The input phrases that are to be converted into speech. 77 | init_time (Optional[int]): The time at which the synthesis was initialized. 78 | 79 | Returns: 80 | StreamHandler: A handler for the stream of synthesized words. 81 | """ 82 | # Record the time at which the synthesis was initialized pre-lock, in order to account for future interruptions. 83 | init_time = time.perf_counter_ns() if init_time is None else init_time 84 | with self.__class__._synthesis_lock: 85 | if self._interrupt >= init_time: 86 | return tuple() 87 | else: 88 | self._queue.reset() 89 | self._iterable = SpeechSynthesisHandler( 90 | phrases=phrases, synthesizer=self._synthesizer, queue=self._queue 91 | ) 92 | 93 | for i in self._iterable: 94 | yield i 95 | 96 | def _init_synthesizer(self, output_format: SpeechSynthesisOutputFormat) -> None: 97 | """ 98 | Initializes the speech synthesizer. 99 | """ 100 | logging.debug(f"SpeechSynthesisService initialized") 101 | 102 | # Initialize the speech configuration with the Azure subscription and region 103 | self._speech_config = speechsdk.SpeechConfig( 104 | subscription=os.environ.get(EnvVar.AZURE_SPEECH_KEY.value), 105 | region=os.environ.get(EnvVar.AZURE_SPEECH_REGION.value), 106 | ) 107 | 108 | # Initialize the speech synthesizer with the speech configuration 109 | self._synthesizer = speechsdk.SpeechSynthesizer(speech_config=self._speech_config) 110 | 111 | # Set the speech synthesis output format to the specified output format 112 | self._speech_config.set_speech_synthesis_output_format(output_format) 113 | 114 | # Connect the speech synthesizer events to their corresponding callbacks 115 | self._callbacks_connect() 116 | 117 | # Creating a new instance of Connection class 118 | self._connection = speechsdk.Connection.from_speech_synthesizer(self._synthesizer) 119 | 120 | # Preconnecting the speech synthesizer for reduced latency 121 | self._connection.open(for_continuous_recognition=True) 122 | 123 | def _callback_completed(self, event: speechsdk.SessionEventArgs) -> None: 124 | """ 125 | Callback function for synthesis completed event. Signals that the synthesis process has been stopped/canceled. 126 | 127 | Args: 128 | event (speechsdk.SessionEventArgs): Event arguments containing information about the synthesis completed. 129 | """ 130 | logging.debug("SpeechSynthesisService disconnected") 131 | self._queue.close() 132 | 133 | def _callback_started(self, event: speechsdk.SessionEventArgs) -> None: 134 | """ 135 | Callback function for synthesis started event. Signals that the synthesis process has started. 136 | 137 | Args: 138 | event (speechsdk.SessionEventArgs): Event arguments containing information about the synthesis started. 139 | """ 140 | logging.debug("SpeechSynthesisService connected") 141 | self._synthesis_start = time.perf_counter_ns() 142 | 143 | @staticmethod 144 | @nb.njit(cache=True) 145 | def _calculate_offset( 146 | start_synthesis_time: float, audio_offset: float, total_seconds: float, word_length: int 147 | ) -> float: 148 | """ 149 | Calculates the offset of the word in the stream. 150 | 151 | Args: 152 | start_synthesis_time (float): The time at which the synthesis started. 153 | audio_offset (float): The audio offset of the word. 154 | total_seconds (float): The total seconds of the word. 155 | word_length (int): The length of the word. 156 | 157 | Returns: 158 | float: The offset of the word in the stream. 159 | """ 160 | return start_synthesis_time + 100 * audio_offset + 1e9 * total_seconds / word_length 161 | 162 | def _callback_word_boundary(self, event: speechsdk.SessionEventArgs) -> None: 163 | """ 164 | Callback function for word boundary event. Signals that a word boundary has been reached and provides the word 165 | and timing information. 166 | 167 | Args: 168 | event (speechsdk.SessionEventArgs): Event arguments containing information about the word boundary. 169 | """ 170 | # Check if the event is still active based on the result_id. 171 | time = self._calculate_offset( 172 | start_synthesis_time=self._synthesis_start, 173 | audio_offset=event.audio_offset, 174 | total_seconds=event.duration.total_seconds(), 175 | word_length=event.word_length, 176 | ) 177 | data = { 178 | "time": time, 179 | "word": Word( 180 | text=( 181 | event.text 182 | if event.boundary_type == speechsdk.SpeechSynthesisBoundaryType.Punctuation 183 | else " " + event.text 184 | ), 185 | offset=datetime.timedelta(microseconds=event.audio_offset / 10), 186 | duration=event.duration, 187 | ), 188 | } 189 | self._queue.put(data) 190 | 191 | def _callbacks_connect(self): 192 | """ 193 | Connect the synthesis events to their corresponding callback methods. 194 | """ 195 | self._synthesizer.synthesis_started.connect(self._callback_started) 196 | self._synthesizer.synthesis_word_boundary.connect(self._callback_word_boundary) 197 | self._synthesizer.synthesis_canceled.connect(self._callback_completed) 198 | self._synthesizer.synthesis_completed.connect(self._callback_completed) 199 | -------------------------------------------------------------------------------- /banterbot/types/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.types.wordjson import WordJSON 2 | 3 | __all__ = ["WordJSON"] 4 | -------------------------------------------------------------------------------- /banterbot/types/wordjson.py: -------------------------------------------------------------------------------- 1 | from typing import TypedDict 2 | 3 | 4 | class WordJSON(TypedDict): 5 | """ 6 | A type definition class defining the format of the individually recognized words from a SpeechRecognitionEventArgs 7 | event's json attribute. 8 | 9 | Attributes: 10 | Word (str): The recognized word from speech. 11 | Offset (int): The start time of the recognized word in microseconds. 12 | Duration (int): The length of time the recognized word took in microseconds. 13 | Confidence (float): Confidence score of the recognition for the word. 14 | """ 15 | 16 | Word: str 17 | Offset: int 18 | Duration: int 19 | Confidence: float 20 | -------------------------------------------------------------------------------- /banterbot/utils/__init__.py: -------------------------------------------------------------------------------- 1 | from banterbot.utils.closeable_queue import CloseableQueue 2 | from banterbot.utils.indexed_event import IndexedEvent 3 | from banterbot.utils.nlp import NLP 4 | from banterbot.utils.thread_queue import ThreadQueue 5 | 6 | __all__ = ["CloseableQueue", "IndexedEvent", "NLP", "ThreadQueue"] 7 | -------------------------------------------------------------------------------- /banterbot/utils/closeable_queue.py: -------------------------------------------------------------------------------- 1 | import queue 2 | from collections.abc import Generator 3 | from typing import Any, Optional 4 | 5 | from typing_extensions import Self 6 | 7 | from banterbot.utils.indexed_event import IndexedEvent 8 | 9 | 10 | class CloseableQueue: 11 | """ 12 | A queue that can be closed to prevent further puts. This is useful for when you have a producer thread that you 13 | want to stop once it has finished producing items, but you don't want to stop the consumer thread from consuming 14 | items that have already been produced. Must be used as a context manager on the producer thread to ensure that the 15 | queue is closed when the producer thread exits. 16 | 17 | The intended use case is that the producer thread will put items into the queue until it is finished, then close the 18 | queue and exit. The consumer thread will then consume the items in the queue until it is empty, ideally using a 19 | `for` loop to ensure that it exits when the queue is empty and closed. 20 | 21 | If a `for` loop is not used by the consumer thread, then the consumer thread can also use a `while` loop to consume 22 | items from the queue. In this case, the `while` loop's condition should be `while not queue.closed()` to ensure that 23 | the consumer thread exits when the queue is empty and closed. 24 | """ 25 | 26 | def __init__(self, maxsize: int = 0) -> None: 27 | self._maxsize = maxsize 28 | self.reset() 29 | 30 | def close(self) -> None: 31 | self._closed = True 32 | self._indexed_event.increment() 33 | 34 | def kill(self) -> None: 35 | self._killed = True 36 | self._closed = True 37 | self._indexed_event.increment() 38 | 39 | def put(self, item: Any, block: bool = True, timeout: Optional[float] = None) -> None: 40 | self._queue.put(item, block, timeout) 41 | self._indexed_event.increment() 42 | 43 | def get(self, block: bool = True, timeout: Optional[float] = None) -> Any: 44 | return self._queue.get(block, timeout) 45 | 46 | def finished(self) -> bool: 47 | return self._closed and self._queue.empty() 48 | 49 | def reset(self) -> None: 50 | self._queue = queue.Queue(maxsize=self._maxsize) 51 | self._closed = False 52 | self._killed = False 53 | self._indexed_event = IndexedEvent() 54 | 55 | def __iter__(self) -> Generator[Any, None, None]: 56 | while not self.finished(): 57 | self._indexed_event.wait() 58 | self._indexed_event.decrement() 59 | if self._killed: 60 | break 61 | elif not self._queue.empty(): 62 | yield self._queue.get() 63 | self.reset() 64 | 65 | def __enter__(self) -> Self: 66 | return self 67 | 68 | def __exit__(self) -> None: 69 | self.close() 70 | -------------------------------------------------------------------------------- /banterbot/utils/indexed_event.py: -------------------------------------------------------------------------------- 1 | import threading 2 | 3 | 4 | class IndexedEvent(threading.Event): 5 | """ 6 | A thread synchronization event that uses a counter to manage iterations in a producer-consumer scenario. This class 7 | is ideal for situations where a consumer thread processes data chunks provided by a producer thread. The counter 8 | ensures that the consumer processes each chunk of data exactly once and waits when no more data is available. 9 | 10 | This class extends threading.Event, adding a counter to control the number of times the event allows passage before 11 | resetting. It is useful for controlled processing of data chunks in multi-threaded applications, preventing the 12 | consumer from proceeding until new data is available. 13 | """ 14 | 15 | def __init__(self, initial_counter: int = 0) -> None: 16 | """ 17 | Initializes the IndexedEvent with an optional initial counter value, which represents the number of items 18 | initially available for processing. 19 | 20 | Args: 21 | initial_counter (int): The initial count of items available for processing. Must be non-negative. 22 | 23 | Raises: 24 | ValueError: If the initial counter is set to a negative value. 25 | """ 26 | super().__init__() 27 | self._lock: threading.Lock = threading.Lock() 28 | self._counter: int = initial_counter 29 | 30 | @property 31 | def counter(self) -> int: 32 | """ 33 | Retrieves the current value of the counter, indicating the number of data chunks available for processing. 34 | 35 | Returns: 36 | int: The current number of unprocessed data chunks. 37 | """ 38 | return self._counter 39 | 40 | @counter.setter 41 | def counter(self, N: int) -> None: 42 | """ 43 | Wrapper for method `set`. 44 | 45 | Args: 46 | N (int): The number of data chunks available. Must be non-negative. 47 | 48 | Raises: 49 | ValueError: If N is less than 1 or N is not a number. 50 | """ 51 | self.set(N=N) 52 | 53 | def clear(self) -> None: 54 | """ 55 | Resets the event and the counter, typically used to signify that no data is currently available for processing. 56 | """ 57 | # with self._lock: 58 | super().clear() 59 | self._counter = 0 60 | 61 | def increment(self, N: int = 1) -> None: 62 | """ 63 | Increments the counter by a specified amount, indicating that new data chunks are available. It also sets the 64 | event, allowing the consumer to resume processing. 65 | 66 | Args: 67 | N (int): The number of new data chunks added. Must be non-negative. 68 | 69 | Raises: 70 | ValueError: If N is less than 1 or N is not a number. 71 | """ 72 | # with self._lock: 73 | self._counter += N 74 | if self._counter > 0: 75 | super().set() 76 | 77 | def decrement(self, N: int = 1) -> None: 78 | """ 79 | Decrements the counter by a specified amount. It also clears the event if zero is reached, blocking the 80 | consumer. 81 | 82 | Args: 83 | N (int): The amount to decrement the counter by. Must be non-negative. 84 | """ 85 | # with self._lock: 86 | self._counter -= N 87 | if self._counter > 0: 88 | super().set() 89 | else: 90 | super().clear() 91 | 92 | def is_set(self) -> bool: 93 | """ 94 | Checks if the event is set, meaning data is available for processing. 95 | 96 | Returns: 97 | bool: True if data is available, False otherwise. 98 | """ 99 | return super().is_set() 100 | 101 | def set(self, N: int = 1) -> None: 102 | """ 103 | Directly sets the counter to a specified value, indicating the exact number of data chunks available. 104 | 105 | Args: 106 | N (int): The number of data chunks available. Must be non-negative. 107 | 108 | Raises: 109 | ValueError: If N is less than 1 or N is not a number. 110 | """ 111 | # with self._lock: 112 | if N > 0: 113 | super().set() 114 | else: 115 | super().clear() 116 | self._counter = N 117 | -------------------------------------------------------------------------------- /banterbot/utils/nlp.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import re 3 | from typing import Generator 4 | 5 | import spacy 6 | 7 | from banterbot.data.enums import SpaCyLangModel 8 | 9 | 10 | class NLP: 11 | """ 12 | A comprehensive toolkit that provides a set of Natural Language Processing utilities. It leverages the capabilities 13 | of the spaCy package. The toolkit is designed to automatically download the necessary models if they are not 14 | available. 15 | 16 | One of the main features of this toolkit is the intelligent model selection mechanism. It is designed to select the 17 | most appropriate and lightweight model for each specific task, balancing between computational efficiency and task 18 | performance. 19 | """ 20 | 21 | _models = { 22 | "senter": None, 23 | "splitter": None, 24 | SpaCyLangModel.EN_CORE_WEB_SM: None, 25 | SpaCyLangModel.EN_CORE_WEB_MD: None, 26 | SpaCyLangModel.EN_CORE_WEB_LG: None, 27 | } 28 | 29 | @classmethod 30 | def install_upgrade_all_models(cls) -> None: 31 | """ 32 | Lazily checks if models are already installed, and installs any that are missing. 33 | """ 34 | installed = spacy.util.get_installed_models() 35 | for model in SpaCyLangModel: 36 | if model.value not in installed: 37 | cls._download_model(model.value) 38 | 39 | @classmethod 40 | def load_all_models(cls) -> None: 41 | """ 42 | Preloads all available models. 43 | """ 44 | for model in cls._models: 45 | cls.model(model) 46 | 47 | @classmethod 48 | def model(cls, name: str) -> spacy.language.Language: 49 | """ 50 | Returns the specified spaCy model lazily by loading models the first time they are called, then storing them in 51 | the `cls._models` dictionary. 52 | 53 | Args: 54 | name (str): The name of the spaCy model to return. 55 | 56 | Returns: 57 | spacy.language.Language: The requested spaCy model. 58 | """ 59 | if name == "senter" and cls._models["senter"] is None: 60 | # Define a set of pipeline components to disable for the sentence senter. 61 | senter_disable = ["tok2vec", "tagger", "parser", "attribute_ruler", "lemmatizer", "ner"] 62 | senter = cls._load_model(name=SpaCyLangModel.EN_CORE_WEB_SM.value, disable=senter_disable) 63 | # Enable the sentence segmentation pipeline component for the senter model. 64 | senter.enable_pipe("senter") 65 | 66 | logging.debug("NLP initializing model: `senter`") 67 | cls._models["senter"] = senter 68 | 69 | elif name == "splitter" and cls._models["splitter"] is None: 70 | rules = {} 71 | splitter = cls._load_model(name=SpaCyLangModel.EN_CORE_WEB_MD.value) 72 | # Customize the tokenization rules for the word splitter in order to prevent splitting of contractions. 73 | ignore = ("'", "’", "‘") 74 | for key, value in splitter.tokenizer.rules.items(): 75 | if all(i not in key for i in ignore): 76 | rules[key] = value 77 | splitter.tokenizer.rules = rules 78 | logging.debug("NLP initializing model: `splitter`") 79 | cls._models["splitter"] = splitter 80 | 81 | elif name == SpaCyLangModel.EN_CORE_WEB_SM and cls._models[SpaCyLangModel.EN_CORE_WEB_SM] is None: 82 | logging.debug(f"NLP initializing model: `{SpaCyLangModel.EN_CORE_WEB_SM.value}`") 83 | cls._models[SpaCyLangModel.EN_CORE_WEB_SM] = cls._load_model(name=SpaCyLangModel.EN_CORE_WEB_SM.value) 84 | 85 | elif name == SpaCyLangModel.EN_CORE_WEB_MD and cls._models[SpaCyLangModel.EN_CORE_WEB_MD] is None: 86 | logging.debug(f"NLP initializing model: `{SpaCyLangModel.EN_CORE_WEB_MD.value}`") 87 | cls._models[SpaCyLangModel.EN_CORE_WEB_MD] = cls._load_model(name=SpaCyLangModel.EN_CORE_WEB_MD.value) 88 | 89 | elif name == SpaCyLangModel.EN_CORE_WEB_LG and cls._models[SpaCyLangModel.EN_CORE_WEB_LG] is None: 90 | logging.debug(f"NLP initializing model: `{SpaCyLangModel.EN_CORE_WEB_LG.value}`") 91 | cls._models[SpaCyLangModel.EN_CORE_WEB_LG] = cls._load_model(name=SpaCyLangModel.EN_CORE_WEB_LG.value) 92 | 93 | return cls._models[name] 94 | 95 | @classmethod 96 | def segment_sentences(cls, string: str, whitespace: bool = True) -> tuple[str, ...]: 97 | """ 98 | Splits a text string into individual sentences using a specialized spaCy model. The model is a lightweight 99 | version of `en_core_web_sm` designed specifically for sentence segmentation. 100 | 101 | Args: 102 | string (str): The input text string. 103 | whitespace (str): If True, keep whitespace at the beginning/end of sentences; if False, strip it. 104 | 105 | Returns: 106 | tuple[str, ...]: A tuple of individual sentences as strings. 107 | """ 108 | return tuple( 109 | sentence.text_with_ws if whitespace else sentence.text for sentence in cls.model("senter")(string).sents 110 | ) 111 | 112 | @classmethod 113 | def segment_words(cls, string: str, whitespace: bool = True) -> tuple[str, ...]: 114 | """ 115 | Splits a text string into individual words using a specialized spaCy model. The model is customized version of 116 | `en_core_web_md` in which words are not split on apostrophes, in order to preserve contractions. 117 | 118 | Args: 119 | string (str): The input text string. 120 | whitespace (str): If True, include whitespace characters between words; if False, omit it. 121 | 122 | Returns: 123 | tuple[str, ...]: A tuple of individual words as strings. 124 | """ 125 | words = [] 126 | for word in cls.model("splitter")(string): 127 | words.append(word.text) 128 | if whitespace and word.whitespace_: 129 | words.append(word.whitespace_) 130 | 131 | return tuple(words) 132 | 133 | @classmethod 134 | def extract_keywords(cls, strings: list[str]) -> tuple[tuple[str, ...]]: 135 | """ 136 | Extracts keywords from a list of text strings using the `en_core_web_md` spaCy model. 137 | 138 | Args: 139 | strings (list[str]): A list of strings. 140 | 141 | Returns: 142 | tuple[str, ...]: A tuple of extracted keywords as strings. 143 | """ 144 | docs = cls.model(SpaCyLangModel.EN_CORE_WEB_MD).pipe(strings) 145 | return tuple(tuple(str(entity) for entity in doc.ents) for doc in docs) 146 | 147 | @classmethod 148 | def tokenize(cls, strings: list[str]) -> Generator[spacy.tokens.doc.Doc, None, None]: 149 | """ 150 | Given a string or list of strings, returns tokenized versions of the strings as a generator. 151 | 152 | Args: 153 | strings (list[str]): A list of strings. 154 | 155 | Returns: 156 | Generator[spacy.tokens.doc.Doc, None, None]: A stream of `spacy.tokens.doc.Doc` instances. 157 | """ 158 | return cls.model(SpaCyLangModel.EN_CORE_WEB_LG).pipe(strings) 159 | 160 | @classmethod 161 | def _download_model(cls, name: str) -> None: 162 | """ 163 | Downloads a spaCy model by its name. It also provides information about the download process to the user. 164 | 165 | Args: 166 | name (str): The name of the spaCy model to download. 167 | """ 168 | logging.info(f'Downloading spaCy language model "{name}". This will only happen once.\n\n\n') 169 | spacy.cli.download(name) 170 | logging.info(f'\n\n\nFinished download of spaCy language model "{name}".') 171 | 172 | @classmethod 173 | def _update_model(cls, name: str) -> None: 174 | """ 175 | Downloads a spaCy model by its name. It also provides information about the download process to the user. 176 | 177 | Args: 178 | name (str): The name of the spaCy model to download. 179 | """ 180 | logging.info(f'Updating spaCy language model "{name}" for spaCy version {spacy.__version__}.\n\n\n') 181 | spacy.cli.download(name) 182 | logging.info(f'\n\n\nFinished update of spaCy language model "{name}" for spaCy version {spacy.__version__}.') 183 | 184 | @classmethod 185 | def _load_model(cls, *, name: str, **kwargs) -> spacy.language.Language: 186 | """ 187 | Loads a spaCy model by its name. If the model is not available, it is downloaded automatically. 188 | 189 | Args: 190 | name (str): The name of the spaCy model to load. 191 | **kwargs: Additional keyword arguments for the spacy.load function. 192 | 193 | Returns: 194 | spacy.language.Language: The loaded spaCy model. 195 | """ 196 | # If the model is not available, download it. 197 | try: 198 | model = spacy.load(name, **kwargs) 199 | except OSError: 200 | cls._download_model(name) 201 | model = spacy.load(name, **kwargs) 202 | 203 | # Version checking to ensure that the model is compatible with the installed version of spaCy. 204 | model_version = re.match(r"(\d+\.\d+).\d+", model.meta["version"])[1] 205 | spacy_version = re.match(r"(\d+\.\d+).\d+", spacy.__version__)[1] 206 | 207 | # If the model is not compatible with the installed version of spaCy, update the model. 208 | if model_version != spacy_version: 209 | cls._update_model(name) 210 | model = spacy.load(name, **kwargs) 211 | 212 | return model 213 | 214 | 215 | # Upon import, NLP downloads any missing required models. 216 | NLP.install_upgrade_all_models() 217 | -------------------------------------------------------------------------------- /banterbot/utils/thread_queue.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | 4 | 5 | class ThreadQueue: 6 | """ 7 | A class for managing and executing tasks in separate threads. 8 | 9 | This class maintains a queue of tasks to be executed. Each task is a Thread object, which is executed in its own 10 | thread. If there is a task in the queue that hasn't started executing yet, it will be prevented from running when a 11 | new task is added unless it is declared unskippable. 12 | """ 13 | 14 | def __init__(self): 15 | logging.debug(f"ThreadQueue initialized") 16 | self._lock = threading.Lock() 17 | self._event_queue: list[threading.Event] = [] 18 | 19 | def add_task(self, thread: threading.Thread, unskippable: bool = False) -> None: 20 | """ 21 | Add a new task to the queue. 22 | 23 | This method adds a new task to the queue and starts a wrapper thread to manage its execution. The wrapper thread 24 | is responsible for waiting for the previous task to complete, executing the current task if it is unskippable or 25 | the last task in the queue, and setting the event for the next task. 26 | 27 | Args: 28 | thread (threading.Thread): The thread to be added to the queue. 29 | unskippable (bool, optional): Whether the thread should be executed even if a new task is queued. 30 | """ 31 | with self._lock: 32 | index = len(self._event_queue) 33 | self._event_queue.append(threading.Event()) 34 | 35 | wrapper_thread = threading.Thread( 36 | target=self._thread_wrapper, args=(thread, index, unskippable), daemon=thread.daemon 37 | ) 38 | wrapper_thread.start() 39 | 40 | def is_alive(self) -> bool: 41 | """ 42 | Check if the last task in the queue is still running. 43 | 44 | Returns: 45 | bool: True if the last task is still running, False otherwise. 46 | """ 47 | if not self._event_queue: 48 | return False 49 | else: 50 | return not self._event_queue[-1].is_set() 51 | 52 | def _thread_wrapper(self, thread: threading.Thread, index: int, unskippable: bool) -> None: 53 | """ 54 | A wrapper function for executing threads in the queue. 55 | 56 | This function is responsible for waiting for the previous task to complete before starting the current task, and 57 | setting the event for the next task in the queue. If the current task is skippable and not the last task in the 58 | queue, it will not be executed. 59 | 60 | Args: 61 | thread (threading.Thread): The thread to be executed. 62 | index (int): The index of the thread in the event queue. 63 | unskippable (bool): Whether the thread should be executed even if a new task is added to the queue. 64 | """ 65 | if index > 0: 66 | self._event_queue[index - 1].wait() 67 | 68 | if unskippable or index == len(self._event_queue) - 1: 69 | logging.debug(f"ThreadQueue thread {index} started") 70 | thread.start() 71 | thread.join() 72 | else: 73 | logging.debug(f"ThreadQueue thread {index} skipped") 74 | 75 | self._event_queue[index].set() 76 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = . 9 | BUILDDIR = _build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | -------------------------------------------------------------------------------- /docs/banterbot.data.rst: -------------------------------------------------------------------------------- 1 | banterbot.data package 2 | ====================== 3 | 4 | banterbot.data.enums module 5 | --------------------------- 6 | 7 | .. automodule:: banterbot.data.enums 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | banterbot.data.prompts module 13 | ----------------------------- 14 | 15 | .. automodule:: banterbot.data.prompts 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | -------------------------------------------------------------------------------- /docs/banterbot.exceptions.rst: -------------------------------------------------------------------------------- 1 | banterbot.exceptions package 2 | ============================ 3 | 4 | banterbot.exceptions.format\_mismatch\_error module 5 | --------------------------------------------------- 6 | 7 | .. automodule:: banterbot.exceptions.format_mismatch_error 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/banterbot.extensions.rst: -------------------------------------------------------------------------------- 1 | banterbot.extensions package 2 | ============================ 3 | 4 | banterbot.extensions.interface module 5 | ------------------------------------- 6 | 7 | .. automodule:: banterbot.extensions.interface 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | banterbot.extensions.option\_selector module 13 | -------------------------------------------- 14 | 15 | .. automodule:: banterbot.extensions.option_selector 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | banterbot.extensions.prosody\_selector module 21 | --------------------------------------------- 22 | 23 | .. automodule:: banterbot.extensions.prosody_selector 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | -------------------------------------------------------------------------------- /docs/banterbot.gui.rst: -------------------------------------------------------------------------------- 1 | banterbot.gui package 2 | ===================== 3 | 4 | banterbot.gui.tk\_interface module 5 | ---------------------------------- 6 | 7 | .. automodule:: banterbot.gui.tk_interface 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | -------------------------------------------------------------------------------- /docs/banterbot.handlers.rst: -------------------------------------------------------------------------------- 1 | banterbot.handlers package 2 | ========================== 3 | 4 | banterbot.handlers.speech\_recognition\_handler module 5 | ------------------------------------------------------ 6 | 7 | .. automodule:: banterbot.handlers.speech_recognition_handler 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | banterbot.handlers.speech\_synthesis\_handler module 13 | ---------------------------------------------------- 14 | 15 | .. automodule:: banterbot.handlers.speech_synthesis_handler 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | banterbot.handlers.stream\_handler module 21 | ----------------------------------------- 22 | 23 | .. automodule:: banterbot.handlers.stream_handler 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: -------------------------------------------------------------------------------- /docs/banterbot.managers.rst: -------------------------------------------------------------------------------- 1 | banterbot.managers package 2 | ========================== 3 | 4 | banterbot.managers.azure\_neural\_voice\_manager module 5 | ------------------------------------------------------- 6 | 7 | .. automodule:: banterbot.managers.azure_neural_voice_manager 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | banterbot.managers.memory\_chain module 13 | --------------------------------------- 14 | 15 | .. automodule:: banterbot.managers.memory_chain 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | banterbot.managers.openai\_model\_manager module 21 | ------------------------------------------------ 22 | 23 | .. automodule:: banterbot.managers.openai_model_manager 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | banterbot.managers.resource\_manager module 29 | ------------------------------------------- 30 | 31 | .. automodule:: banterbot.managers.resource_manager 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | banterbot.managers.stream\_manager module 37 | ----------------------------------------- 38 | 39 | .. automodule:: banterbot.managers.stream_manager 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | -------------------------------------------------------------------------------- /docs/banterbot.models.rst: -------------------------------------------------------------------------------- 1 | banterbot.models package 2 | ======================== 3 | 4 | banterbot.models.azure\_neural\_voice\_profile module 5 | ----------------------------------------------------- 6 | 7 | .. automodule:: banterbot.models.azure_neural_voice_profile 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | banterbot.models.memory module 13 | ------------------------------ 14 | 15 | .. automodule:: banterbot.models.memory 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | banterbot.models.message module 21 | ------------------------------- 22 | 23 | .. automodule:: banterbot.models.message 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | banterbot.models.number module 29 | ------------------------------ 30 | 31 | .. automodule:: banterbot.models.number 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: 35 | 36 | banterbot.models.openai\_model module 37 | ------------------------------------- 38 | 39 | .. automodule:: banterbot.models.openai_model 40 | :members: 41 | :undoc-members: 42 | :show-inheritance: 43 | 44 | banterbot.models.phrase module 45 | ------------------------------ 46 | 47 | .. automodule:: banterbot.models.phrase 48 | :members: 49 | :undoc-members: 50 | :show-inheritance: 51 | 52 | banterbot.models.speech\_recognition\_input module 53 | --------------------------------------------------- 54 | 55 | .. automodule:: banterbot.models.speech_recognition_input 56 | :members: 57 | :undoc-members: 58 | :show-inheritance: 59 | 60 | banterbot.models.stream\_log\_entry module 61 | ------------------------------------------ 62 | 63 | .. automodule:: banterbot.models.stream_log_entry 64 | :members: 65 | :undoc-members: 66 | :show-inheritance: 67 | 68 | banterbot.models.word module 69 | ---------------------------- 70 | 71 | .. automodule:: banterbot.models.word 72 | :members: 73 | :undoc-members: 74 | :show-inheritance: 75 | -------------------------------------------------------------------------------- /docs/banterbot.rst: -------------------------------------------------------------------------------- 1 | BanterBot Package 2 | ================= 3 | 4 | Classes 5 | ------- 6 | 7 | OpenAIService 8 | ~~~~~~~~~~~~~ 9 | 10 | .. autoclass:: banterbot.services.openai_service.OpenAIService 11 | :members: 12 | :undoc-members: 13 | :special-members: 14 | :show-inheritance: 15 | 16 | SpeechSynthesisService 17 | ~~~~~~~~~~~~~~~~~~~~~~ 18 | 19 | .. autoclass:: banterbot.services.speech_synthesis_service.SpeechSynthesisService 20 | :members: 21 | :undoc-members: 22 | :special-members: 23 | :show-inheritance: 24 | 25 | SpeechRecognitionService 26 | ~~~~~~~~~~~~~~~~~~~~~~~~ 27 | 28 | .. autoclass:: banterbot.services.speech_recognition_service.SpeechRecognitionService 29 | :members: 30 | :undoc-members: 31 | :special-members: 32 | :show-inheritance: 33 | 34 | Interface 35 | ~~~~~~~~~ 36 | 37 | .. autoclass:: banterbot.extensions.interface.Interface 38 | :members: 39 | :undoc-members: 40 | :special-members: 41 | :show-inheritance: 42 | 43 | TKInterface 44 | ~~~~~~~~~~~ 45 | 46 | .. autoclass:: banterbot.gui.tk_interface.TKInterface 47 | :members: 48 | :undoc-members: 49 | :special-members: 50 | :show-inheritance: 51 | 52 | Subpackages 53 | ----------- 54 | 55 | .. toctree:: 56 | :maxdepth: 4 57 | 58 | banterbot.data 59 | banterbot.exceptions 60 | banterbot.extensions 61 | banterbot.gui 62 | banterbot.handlers 63 | banterbot.managers 64 | banterbot.models 65 | banterbot.services 66 | banterbot.types 67 | banterbot.utils 68 | -------------------------------------------------------------------------------- /docs/banterbot.services.rst: -------------------------------------------------------------------------------- 1 | banterbot.services package 2 | ========================== 3 | 4 | banterbot.services.openai\_service module 5 | ----------------------------------------- 6 | 7 | .. automodule:: banterbot.services.openai_service 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | banterbot.services.speech\_synthesis\_service module 13 | ---------------------------------------------------- 14 | 15 | .. automodule:: banterbot.services.speech_synthesis_service 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | banterbot.services.speech\_recognition\_service module 21 | ------------------------------------------------------ 22 | 23 | .. automodule:: banterbot.services.speech_recognition_service 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | 29 | -------------------------------------------------------------------------------- /docs/banterbot.types.rst: -------------------------------------------------------------------------------- 1 | banterbot.types package 2 | ======================= 3 | 4 | banterbot.types.wordjson module 5 | ------------------------------- 6 | 7 | .. automodule:: banterbot.types.wordjson 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: -------------------------------------------------------------------------------- /docs/banterbot.utils.rst: -------------------------------------------------------------------------------- 1 | banterbot.utils package 2 | ======================= 3 | 4 | banterbot.utils.closeable\_queue module 5 | --------------------------------------- 6 | 7 | .. automodule:: banterbot.utils.closeable_queue 8 | :members: 9 | :undoc-members: 10 | :show-inheritance: 11 | 12 | banterbot.utils.indexed\_event module 13 | ------------------------------------- 14 | 15 | .. automodule:: banterbot.utils.indexed_event 16 | :members: 17 | :undoc-members: 18 | :show-inheritance: 19 | 20 | banterbot.utils.nlp module 21 | -------------------------- 22 | 23 | .. automodule:: banterbot.utils.nlp 24 | :members: 25 | :undoc-members: 26 | :show-inheritance: 27 | 28 | banterbot.utils.thread\_queue module 29 | ------------------------------------ 30 | 31 | .. automodule:: banterbot.utils.thread_queue 32 | :members: 33 | :undoc-members: 34 | :show-inheritance: -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | import os 10 | import sys 11 | 12 | sys.path.insert(0, os.path.abspath("..")) 13 | 14 | project = "BanterBot" 15 | copyright = "2023, Gabriel S. Cabrera" 16 | author = "Gabriel S. Cabrera" 17 | 18 | # -- General configuration --------------------------------------------------- 19 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 20 | 21 | extensions = [ 22 | "sphinx.ext.autodoc", 23 | "sphinx.ext.viewcode", 24 | "sphinx.ext.napoleon", 25 | ] 26 | 27 | 28 | templates_path = ["_templates"] 29 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 30 | 31 | 32 | # -- Options for HTML output ------------------------------------------------- 33 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 34 | 35 | html_theme = "sphinx_rtd_theme" 36 | html_static_path = ["_static"] 37 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. BanterBot documentation master file, created by 2 | sphinx-quickstart on Sun May 28 21:02:41 2023. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to BanterBot's documentation! 7 | ===================================== 8 | 9 | BanterBot is a user-friendly chatbot application that leverages OpenAI 10 | models for generating context-aware responses, Azure Neural Voices for 11 | text-to-speech synthesis, and Azure speech-to-text recognition. The 12 | package offers a comprehensive toolkit for building chatbot applications 13 | with an intuitive interface and a suite of utilities. 14 | 15 | Features 16 | -------- 17 | 18 | - Utilizes OpenAI models to generate context-aware responses 19 | - Leverages Azure Neural Voices for premium text-to-speech 20 | synthesis 21 | - Offers a wide range of output formats, multilingual voices, 22 | and speaking styles 23 | - Allows real-time monitoring of the chatbot's responses 24 | - Supports asynchronous speech-to-text microphone input 25 | - Includes an abstract base class for creating custom frontends 26 | for the BanterBot application 27 | - Features a tkinter-based frontend implementation 28 | - Automatically selects an appropriate emotion or tone based on 29 | the conversation context 30 | 31 | Requirements 32 | ------------ 33 | 34 | Three environment variables are required for full functionality: 35 | 36 | - ``OPENAI_API_KEY``: A valid OpenAI API key 37 | - ``AZURE_SPEECH_KEY``: A valid Azure Cognitive Services Speech API key 38 | for text-to-speech and speech-to-text functionality 39 | - ``AZURE_SPEECH_REGION``: The region associated with your Azure 40 | Cognitive Services Speech API key 41 | 42 | Components 43 | ---------- 44 | 45 | TKInterface 46 | ~~~~~~~~~~~ 47 | 48 | A graphical user interface (GUI) establishes a multiplayer conversation 49 | environment where up to nine users can interact with the chatbot 50 | simultaneously. The GUI includes a conversation history area and user 51 | panels with 'Listen' buttons to process user input. It also supports key 52 | bindings for user convenience. 53 | 54 | OpenAIService 55 | ~~~~~~~~~~~~~ 56 | 57 | A class responsible for managing interactions with the OpenAI 58 | ChatCompletion API. It offers functionality to generate responses from 59 | the API based on input messages. It supports generating responses in 60 | their entirety or as a stream of response blocks. 61 | 62 | SpeechSynthesisService 63 | ~~~~~~~~~~~~~~~~~~~~~~ 64 | 65 | A class that handles text-to-speech synthesis using Azure’s Cognitive 66 | Services. It supports a wide range of output formats, voices, and 67 | speaking styles. The synthesized speech can be interrupted, and the 68 | progress can be monitored in real-time. 69 | 70 | SpeechRecognitionService 71 | ~~~~~~~~~~~~~~~~~~~~~~~~ 72 | 73 | A class that provides an interface to convert spoken language into 74 | written text using Azure Cognitive Services. It allows continuous speech 75 | recognition and provides real-time results as sentences are recognized. 76 | 77 | Interface 78 | ~~~~~~~~~ 79 | 80 | An abstract base class for designing frontends for the BanterBot 81 | application. It provides a high-level interface for managing 82 | conversations with the bot, including sending messages, receiving 83 | responses, and updating the conversation area. Accepts both keyboard 84 | inputs and microphone voice inputs. 85 | 86 | Installation 87 | ------------ 88 | 89 | Important Note 90 | ~~~~~~~~~~~~~~ 91 | 92 | BanterBot requires several spaCy language models to run, and will 93 | automatically download them on first-time initialization, if they 94 | are missing or incompatible -- this process can sometimes take a while. 95 | 96 | Pip (Recommended) 97 | ~~~~~~~~~~~~~~~~~ 98 | 99 | BanterBot can be installed or updated using the Python Package Index (PyPi): 100 | 101 | .. code:: bash 102 | 103 | python -m pip install --upgrade banterbot 104 | 105 | 106 | Manual 107 | ~~~~~~ 108 | 109 | To install BanterBot, simply clone the repository and install the 110 | required dependencies: 111 | 112 | .. code:: bash 113 | 114 | git clone https://github.com/gabrielscabrera/banterbot.git 115 | cd banterbot 116 | python -m pip install . 117 | 118 | Usage 119 | ----- 120 | 121 | Launch with Command Line 122 | ~~~~~~~~~~~~~~~~~~~~~~~~ 123 | 124 | Start BanterBot with an enhanced graphical user interface by running the command ``banterbot`` 125 | in your terminal. This GUI allows multiple users to interact with the bot, each with a dedicated 126 | button for speech input and a display for responses. 127 | 128 | - ``--prompt``: Set a system prompt at the beginning of 129 | the conversation (e.g., ``--prompt "You are Grendel the 130 | Quiz Troll, a charismatic troll who loves to host quiz 131 | shows."``). 132 | 133 | - ``--model``: Choose the OpenAI model for conversation 134 | generation. Defaults to GPT-4, but other versions can be 135 | selected if specified in the code. 136 | 137 | - ``--voice``: Select a Microsoft Azure Cognitive Services 138 | text-to-speech voice. The default is "Aria," but other voices 139 | can be specified if available. 140 | 141 | - ``--debug``: Enable debug mode to display additional 142 | information in the terminal for troubleshooting. 143 | 144 | - ``--greet``: Have the bot greet the user upon startup. 145 | 146 | - ``--name``: Assign a name to the assistant for aesthetic 147 | purposes. This does not inform the bot itself; to provide the bot 148 | with information, use the ``--prompt`` flag. 149 | 150 | Here is an example: 151 | 152 | .. code:: bash 153 | 154 | banterbot --greet --model gpt-4-turbo --voice davis --prompt "You are Grondle the Quiz Troll, a charismatic troll who loves to host quiz shows." --name Grondle 155 | 156 | Additionally, you can use `banterbot character` to select a pre-loaded character to interact with. For example: 157 | 158 | .. code:: bash 159 | 160 | banterbot character therapist 161 | 162 | Will start a conversation with `Grendel the Therapy Troll`. To list all available characters, run: 163 | 164 | .. code:: bash 165 | 166 | banterbot character -h 167 | 168 | You can also use ``banterbot voice-search`` to search through all the available voices. For example: 169 | 170 | .. code:: bash 171 | 172 | banterbot voice-search --language en fr 173 | 174 | Will list all English (en) and French (fr) voice models. Run ``banterbot voice-search -h`` for more information. 175 | 176 | 177 | Launch with a Python script 178 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~ 179 | 180 | To use BanterBot in a script, create an instance of the ``TKInterface`` class and call its ``run`` method: 181 | 182 | .. code:: python 183 | 184 | from banterbot import AzureNeuralVoiceManager, OpenAIModelManager, TKInterface 185 | 186 | model = OpenAIModelManager.load("gpt-4o-mini") 187 | voice = AzureNeuralVoiceManager.load("Davis") 188 | assistant_name = "Grendel" 189 | 190 | # Optional system prompt to set up a custom character prior to initializing BanterBot. 191 | system = "You are Grendel the Quiz Troll, a charismatic troll who loves to host quiz shows." 192 | 193 | # The four arguments `model`, `voice`, `system`, and `assistant_name` are optional. 194 | interface = TKInterface(model=model, voice=voice, system=system, assistant_name=assistant_name) 195 | 196 | # Setting `greet` to True instructs BanterBot to initiate the conversation. Otherwise, the user must initiate. 197 | interface.run(greet=True) 198 | 199 | Chat Logs 200 | --------- 201 | 202 | Chat logs are saved in the ``$HOME/Documents/BanterBot/Conversations/`` 203 | directory as individual ``.txt`` files. 204 | 205 | 206 | Documentation 207 | ============= 208 | 209 | .. toctree:: 210 | :maxdepth: 3 211 | :caption: Contents: 212 | 213 | modules 214 | 215 | Indices and tables 216 | ================== 217 | 218 | * :ref:`genindex` 219 | * :ref:`modindex` 220 | * :ref:`search` 221 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/modules.rst: -------------------------------------------------------------------------------- 1 | BanterBot 2 | ========= 3 | 4 | .. toctree:: 5 | :maxdepth: 4 6 | 7 | banterbot 8 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["setuptools", "wheel"] 3 | build-backend = "setuptools.build_meta" 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [metadata] 2 | description_file = README.md 3 | long_description_content_type = text/markdown 4 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # For running tests: python -m unittest discover -s tests 2 | # For building: python setup.py sdist bdist_wheel 3 | # For formatting: autoflake --remove-all-unused-imports -r -i . | isort . | black --preview --line-length 120 . 4 | # For compiling protos: protoc --python_out=. memory.proto 5 | 6 | 7 | import unittest 8 | 9 | from setuptools import find_packages, setup 10 | 11 | dependencies = [ 12 | "azure-cognitiveservices-speech>=1.37.0", 13 | "numba>=0.59.1", 14 | "numpy>=1.26.2", 15 | "openai>=1.23.2", 16 | "protobuf==4.25.1", 17 | "requests>=2.31.0", 18 | "spacy>=3.7.4", 19 | "tiktoken>=0.7.0", 20 | "uuid6>=2024.1.12", 21 | ] 22 | 23 | description = ( 24 | "BanterBot: An OpenAI ChatGPT-powered chatbot with Azure Neural Voices. Supports speech-to-text and text-to-speech " 25 | "interactions with emotional tone selection. Features real-time monitoring and Tkinter frontend." 26 | ) 27 | 28 | url = "https://github.com/GabrielSCabrera/BanterBot" 29 | 30 | with open("README.md", "r") as fs: 31 | long_description = fs.read() 32 | 33 | 34 | def run_tests(): 35 | test_loader = unittest.TestLoader() 36 | test_suite = test_loader.discover("tests", pattern="test_*.py") 37 | return test_suite 38 | 39 | 40 | version = "0.0.16" 41 | setup( 42 | author="Gabriel S. Cabrera", 43 | author_email="gabriel.sigurd.cabrera@gmail.com", 44 | classifiers=[ 45 | "Development Status :: 3 - Alpha", 46 | "Environment :: Console", 47 | "Intended Audience :: Developers", 48 | "Intended Audience :: End Users/Desktop", 49 | "Programming Language :: Python :: 3.9", 50 | "Programming Language :: Python :: 3.10", 51 | "Programming Language :: Python :: 3.11", 52 | "Topic :: Communications :: Chat", 53 | "Topic :: Multimedia :: Sound/Audio :: Speech", 54 | "Topic :: Scientific/Engineering :: Artificial Intelligence", 55 | "Topic :: Utilities", 56 | ], 57 | description=description, 58 | download_url=url + f"/releases/download/v{version}-alpha/BanterBot-{version}.tar.gz", 59 | entry_points={"console_scripts": ["banterbot=banterbot.gui.cli:run"]}, 60 | install_requires=dependencies, 61 | keywords=[ 62 | "tkinter", 63 | "tk", 64 | "gpt", 65 | "gui", 66 | "windows", 67 | "linux", 68 | "cross-platform", 69 | "chatgpt", 70 | "text-to-speech", 71 | "speech-to-text", 72 | "tts", 73 | "stt", 74 | "chatbot", 75 | "openai", 76 | "interactive", 77 | ], 78 | long_description=long_description, 79 | long_description_content_type="text/markdown", 80 | name="BanterBot", 81 | packages=find_packages(), 82 | package_data={"banterbot.protos": ["*.py"], "banterbot.resources": ["*"]}, 83 | python_requires=">=3.9", 84 | test_suite="setup.run_tests", 85 | url=url, 86 | version=version, 87 | ) 88 | --------------------------------------------------------------------------------