diff --git a/code/backend/app.py b/code/backend/app.py new file mode 100644 index 0000000..daaf73e --- /dev/null +++ b/code/backend/app.py @@ -0,0 +1,69 @@ +import sys +import traceback +from datetime import datetime + +from aiohttp import web +from aiohttp.web import Request, Response +from botbuilder.core import ( + TurnContext, +) +from botbuilder.core.integration import aiohttp_error_middleware +from botbuilder.integration.aiohttp import CloudAdapter, ConfigurationBotFrameworkAuthentication +from botbuilder.schema import Activity, ActivityTypes + +from bots.assistant import AssistantBot +from core.config import settings as CONFIG + + +# Create adapter. +# See https://aka.ms/about-bot-adapter to learn more about how bots work. +ADAPTER = CloudAdapter(ConfigurationBotFrameworkAuthentication(CONFIG)) + + +# Catch-all for errors. +async def on_error(context: TurnContext, error: Exception): + # This check writes out errors to console log .vs. app insights. + # NOTE: In production environment, you should consider logging this to Azure + # application insights. + print(f"\n [on_turn_error] unhandled error: {error}", file=sys.stderr) + traceback.print_exc() + + # Send a message to the user + await context.send_activity("The bot encountered an error or bug.") + await context.send_activity( + "To continue to run this bot, please fix the bot source code." + ) + # Send a trace activity if we're talking to the Bot Framework Emulator + if context.activity.channel_id == "emulator": + # Create a trace activity that contains the error object + trace_activity = Activity( + label="TurnError", + name="on_turn_error Trace", + timestamp=datetime.now(datetime.UTC), + type=ActivityTypes.trace, + value=f"{error}", + value_type="https://www.botframework.com/schemas/error", + ) + # Send a trace activity, which will be displayed in Bot Framework Emulator + await context.send_activity(trace_activity) + + +ADAPTER.on_turn_error = on_error + +# Create the Bot +BOT = AssistantBot() + + +# Listen for incoming requests on /api/messages +async def messages(req: Request) -> Response: + return await ADAPTER.process(req, BOT) + + +APP = web.Application(middlewares=[aiohttp_error_middleware]) +APP.router.add_post("/api/messages", messages) + +if __name__ == "__main__": + try: + web.run_app(APP, host="localhost", port=CONFIG.PORT) + except Exception as error: + raise error diff --git a/code/backend/bots/__init__.py b/code/backend/bots/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/code/backend/bots/assistant.py b/code/backend/bots/assistant.py new file mode 100644 index 0000000..ef832b9 --- /dev/null +++ b/code/backend/bots/assistant.py @@ -0,0 +1,40 @@ +from typing import List +from botbuilder.core import ActivityHandler, MessageFactory, TurnContext +from botbuilder.schema import ChannelAccount +from llm.assisstant import assistant_handler + + +class AssistantBot(ActivityHandler): + thread_id = None + + async def on_members_added_activity( + self, members_added: List[ChannelAccount], turn_context: TurnContext + ): + """Onboards new members to the assistant by creating a new thread and adding a initial welcome message. + + members_added (List[ChannelAccount]): The list of channel accounts. + turn_context (TurnContext): The turn context. + RETURNS (str): The welcome message actvity is being returned. + """ + for member in members_added: + if member.id != turn_context.activity.recipient.id: + # Initialize thread in assistant + self.thread_id = assistant_handler.create_thread() + # Respond with welcome message + await turn_context.send_activity("Hello and welcome! I am your personal joke assistant. How can I help you today?") + + async def on_message_activity(self, turn_context: TurnContext): + """Acts upon new messages added to a channel. + + turn_context (TurnContext): The turn context. + RETURNS (str): The assistant message actvity is being returned. + """ + # Interact with assistant + message = assistant_handler.send_message( + message=turn_context.activity.text, + thread_id=self.thread_id, + ) + if message: + return await turn_context.send_activity( + MessageFactory.text(message) + ) diff --git a/code/backend/core/__init__.py b/code/backend/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/code/backend/core/config.py b/code/backend/core/config.py new file mode 100644 index 0000000..aa2ce1d --- /dev/null +++ b/code/backend/core/config.py @@ -0,0 +1,34 @@ +import logging + +from pydantic import Field +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + # General project settings + PROJECT_NAME: str = "BotAssistantSample" + SERVER_NAME: str = "BotAssistantSample" + APP_VERSION: str = "v0.0.1" + PORT: int = 3978 + + # Logging settings + LOGGING_LEVEL: int = logging.INFO + DEBUG: bool = False + APPLICATIONINSIGHTS_CONNECTION_STRING: str = Field( + default="", alias="APPLICATIONINSIGHTS_CONNECTION_STRING" + ) + + # Authentication settings + APP_ID: str = Field(default="", alias="MICROSOFT_APP_ID") + APP_PASSWORD : str = Field(default="", alias="MICROSOFT_APP_PASSWORD") + APP_TENANTID : str = Field(default="", alias="MICROSOFT_APP_TENANTID") + APP_TYPE : str = Field(default="MultiTenant", alias="MICROSOFT_APP_TYPE") + + # Azure Open AI settings + AZURE_OPEN_AI_ENDPOINT: str + AZURE_OPEN_AI_API_VERSION: str + AZURE_OPENAI_API_KEY: str + AZURE_OPENAI_ASSISTANT_ID: str + + +settings = Settings() diff --git a/code/backend/llm/__init__.py b/code/backend/llm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/code/backend/llm/assisstant.py b/code/backend/llm/assisstant.py new file mode 100644 index 0000000..7e8feaf --- /dev/null +++ b/code/backend/llm/assisstant.py @@ -0,0 +1,106 @@ +import time +import logging +import json + +from core.config import settings + +from openai import AzureOpenAI +from openai.types.beta.threads import Run + +class AssistantHandler: + def __init__(self, *args,) -> None: + self.client = AzureOpenAI( + api_key=settings.AZURE_OPENAI_API_KEY, + api_version=settings.AZURE_OPEN_AI_API_VERSION, + azure_endpoint=settings.AZURE_OPEN_AI_ENDPOINT, + ) + self.assistant_id = settings.AZURE_OPENAI_ASSISTANT_ID + + def create_thread(self) -> str: + """Create a thread in the assistant. + + RETURNS (str): Thread id of the newly created thread. + """ + thread = self.client.beta.threads.create() + logging.debug(f"Created thread with thread id: '{thread.id}'") + return thread.id + + def send_message(self, message: str, thread_id: str) -> str | None: + """Send a message to the thread and return the response from the assistant. + + message (str): The message to be sent to the assistant. + thread_id (str): The thread id to which the message should be sent to the assistant. + RETURNS (str): The response from the assistant is being returned. + """ + logging.debug(f"Adding message to thread with thread id: '{thread_id}' - Mesage: '{message}'") + if thread_id is None: + return None + + message = self.client.beta.threads.messages.create( + thread_id=thread_id, + content=message, + role="user", + ) + + run = self.client.beta.threads.runs.create( + thread_id=thread_id, + assistant_id=self.assistant_id, + ) + run = self.__wait_for_run(run=run, thread_id=thread_id) + run = self.__check_for_tools(run=run, thread_id=thread_id) + message = self.__get_assisstant_response(thread_id=thread_id) + + return message + + def __wait_for_run(self, run: Run, thread_id: str) -> Run: + """Wait for the run to complete and return the run once completed. + + run (Run): The run object of the assistant. + thread_id (str): The thread id to which the message should be sent to the assistant. + RETURNS (Run): The run that completed is being returned. + """ + while run.status not in ["completed", "cancelled", "expired", "failed"]: + time.sleep(0.5) + run = self.client.beta.threads.runs.retrieve( + thread_id=thread_id, + run_id=run.id + ) + status = run.status + logging.debug(f"Status of run '{run.id}' in thread '{thread_id}': {status}") + return run + + def __check_for_tools(self, run: Run, thread_id: str) -> Run: + """Acts upon tools configured for the assistant. Runs the thread afterwards and returns the completed run. + + run (Run): The run object of the assistant. + thread_id (str): The thread id to which the message should be sent to the assistant. + RETURNS (Run): The run that completed is being returned. + """ + if run.required_action: + pass + + # Do Action for Function and restart run after submitting action + # run = self.wait_for_run(run=run, thread_id=thread_id) + return run + + def __get_assisstant_response(self, thread_id: str) -> str: + """Gets the latest response from the assistant thread. + + thread_id (str): The thread id from which the latest message should be fetched. + RETURNS (str): The latest message from the assistant in the thread. + """ + # Get message list and load as json object + messages = self.client.beta.threads.messages.list( + thread_id=thread_id, + ) + messages_json = json.loads(messages.model_dump_json()) + + # Extract message from json object + message_data_0 = messages_json.get("data", [{"content": [{"text": {"value": ""}}]}]).pop(0) + message_data_0_content_0 = message_data_0.get("content", [{"text": {"value": ""}}]).pop(0) + first_message_text = message_data_0_content_0.get("text", {"value": ""}).get("value") + logging.debug(f"Response from Assistant in thread '{thread_id}': {first_message_text}") + + return first_message_text + +assistant_handler = AssistantHandler() diff --git a/code/backend/requirements.txt b/code/backend/requirements.txt new file mode 100644 index 0000000..2190d8c --- /dev/null +++ b/code/backend/requirements.txt @@ -0,0 +1,5 @@ +botbuilder-core~=4.16.1 +botbuilder-integration-aiohttp~=4.16.1 +pydantic-settings~=2.2.1 +pydantic~=2.7.0 +openai~=1.42.0