diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 6e66ca3a3..b213342df 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -23,6 +23,7 @@ "ms-azuretools.vscode-bicep", "ms-azuretools.vscode-docker", "ms-python.python", + "ms-python.black-formatter", "ms-python.vscode-pylance", "ms-vscode.vscode-node-azure-pack", "TeamsDevApp.ms-teams-vscode-extension" diff --git a/.github/workflows/unittests.yml b/.github/workflows/unittests.yml index eb0cefec8..7c0f6583f 100644 --- a/.github/workflows/unittests.yml +++ b/.github/workflows/unittests.yml @@ -20,6 +20,6 @@ jobs: architecture: x64 - name: Install dependencies run: | - pip install -r code/requirements.txt -r code/dev-requirements.txt + pip install -r code/requirements.txt -r code/dev-requirements.txt -r code/app/requirements.txt - name: Run Python tests run: python -m pytest --rootdir=code -m "not azure" diff --git a/code/app.py b/code/app.py index c8336cafd..5671e8e84 100644 --- a/code/app.py +++ b/code/app.py @@ -347,13 +347,21 @@ def conversation_azure_byod(): ) +def get_message_orchestrator(): + from backend.batch.utilities.helpers.OrchestratorHelper import Orchestrator + + return Orchestrator() + + +def get_orchestrator_config(): + from backend.batch.utilities.helpers.ConfigHelper import ConfigHelper + + return ConfigHelper.get_active_config_or_default().orchestrator + + @app.route("/api/conversation/custom", methods=["GET", "POST"]) def conversation_custom(): - from backend.batch.utilities.helpers.OrchestratorHelper import ( - Orchestrator, - ) - - message_orchestrator = Orchestrator() + message_orchestrator = get_message_orchestrator() try: user_message = request.json["messages"][-1]["content"] @@ -364,22 +372,12 @@ def conversation_custom(): request.json["messages"][0:-1], ) ) - chat_history = [] - for i, k in enumerate(user_assistant_messages): - if i % 2 == 0: - chat_history.append( - ( - user_assistant_messages[i]["content"], - user_assistant_messages[i + 1]["content"], - ) - ) - from backend.batch.utilities.helpers.ConfigHelper import ConfigHelper messages = message_orchestrator.handle_message( user_message=user_message, - chat_history=chat_history, + chat_history=user_assistant_messages, conversation_id=conversation_id, - orchestrator=ConfigHelper.get_active_config_or_default().orchestrator, + orchestrator=get_orchestrator_config(), ) response_obj = { diff --git a/code/backend/batch/utilities/orchestrator/LangChainAgent.py b/code/backend/batch/utilities/orchestrator/LangChainAgent.py index 445cf1598..5f02f60b7 100644 --- a/code/backend/batch/utilities/orchestrator/LangChainAgent.py +++ b/code/backend/batch/utilities/orchestrator/LangChainAgent.py @@ -89,8 +89,10 @@ def orchestrate( memory_key="chat_history", return_messages=True ) for message in chat_history: - memory.chat_memory.add_user_message(message[0]) - memory.chat_memory.add_ai_message(message[1]) + if message["role"] == "user": + memory.chat_memory.add_user_message(message["content"]) + elif message["role"] == "assistant": + memory.chat_memory.add_ai_message(message["content"]) # Define Agent and Agent Chain llm_chain = LLMChain(llm=llm_helper.get_llm(), prompt=prompt) agent = ZeroShotAgent(llm_chain=llm_chain, tools=self.tools, verbose=True) diff --git a/code/backend/batch/utilities/orchestrator/OpenAIFunctions.py b/code/backend/batch/utilities/orchestrator/OpenAIFunctions.py index 38fcf3fc0..9d35cad26 100644 --- a/code/backend/batch/utilities/orchestrator/OpenAIFunctions.py +++ b/code/backend/batch/utilities/orchestrator/OpenAIFunctions.py @@ -81,8 +81,7 @@ def orchestrate( # Create conversation history messages = [{"role": "system", "content": system_message}] for message in chat_history: - messages.append({"role": "user", "content": message[0]}) - messages.append({"role": "assistant", "content": message[1]}) + messages.append({"role": message["role"], "content": message["content"]}) messages.append({"role": "user", "content": user_message}) result = llm_helper.get_chat_completion_with_functions( diff --git a/code/backend/requirements.txt b/code/backend/requirements.txt index 2b95ed254..9f293ce4a 100644 --- a/code/backend/requirements.txt +++ b/code/backend/requirements.txt @@ -3,9 +3,9 @@ streamlit==1.30.0 openai==1.6.1 matplotlib==3.8.2 plotly==5.18.0 -scipy==1.11.4 -scikit-learn==1.3.2 -transformers==4.36.2 +scipy==1.12.0 +scikit-learn==1.4.0 +transformers==4.37.2 python-dotenv==1.0.1 azure-ai-formrecognizer==3.3.2 azure-storage-blob==12.19.0 @@ -21,8 +21,6 @@ chardet==5.2.0 --extra-index-url https://pkgs.dev.azure.com/azure-sdk/public/_packaging/azure-sdk-for-python/pypi/simple/ azure-search-documents==11.4.0b8 opencensus-ext-azure==1.1.13 -pandas==1.5.1 +pandas==2.2.0 python-docx==1.1.0 azure-keyvault-secrets==4.4.* -# Add dev dependencies here - this will be refactored out by Poetry -pytest==7.4.4 diff --git a/code/requirements.txt b/code/requirements.txt index 9326d4e8f..32859462a 100644 --- a/code/requirements.txt +++ b/code/requirements.txt @@ -1,5 +1,5 @@ azure-identity==1.15.0 -Flask==2.3.2 +Flask==3.0.2 openai==1.6.1 azure-storage-blob==12.19.0 python-dotenv==1.0.1 diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 000000000..6466f5698 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,161 @@ +import os + +from unittest.mock import Mock +from unittest.mock import patch + +from code.app.app import app + + +class TestConfig: + def test_returns_correct_config(self): + response = app.test_client().get("/api/config") + + assert response.status_code == 200 + assert response.json == {"azureSpeechKey": None, "azureSpeechRegion": None} + + +class TestCoversationCustom: + def setup_method(self): + self.orchestrator_config = {"strategy": "langchain"} + self.messages = [ + { + "content": '{"citations": [], "intent": "A question?"}', + "end_turn": False, + "role": "tool", + }, + {"content": "An answer", "end_turn": True, "role": "assistant"}, + ] + self.openai_model = "some-model" + self.body = { + "conversation_id": "123", + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi, how can I help?"}, + {"role": "user", "content": "What is the meaning of life?"}, + ], + } + + @patch("code.app.app.get_message_orchestrator") + @patch("code.app.app.get_orchestrator_config") + def test_converstation_custom_returns_correct_response( + self, get_orchestrator_config_mock, get_message_orchestrator_mock + ): + # given + get_orchestrator_config_mock.return_value = self.orchestrator_config + + message_orchestrator_mock = Mock() + message_orchestrator_mock.handle_message.return_value = self.messages + get_message_orchestrator_mock.return_value = message_orchestrator_mock + + os.environ["AZURE_OPENAI_MODEL"] = self.openai_model + + # when + response = app.test_client().post( + "/api/conversation/custom", + headers={"content-type": "application/json"}, + json=self.body, + ) + + # then + assert response.status_code == 200 + assert response.json == { + "choices": [{"messages": self.messages}], + "created": "response.created", + "id": "response.id", + "model": self.openai_model, + "object": "response.object", + } + + @patch("code.app.app.get_message_orchestrator") + @patch("code.app.app.get_orchestrator_config") + def test_converstation_custom_calls_message_orchestrator_correctly( + self, get_orchestrator_config_mock, get_message_orchestrator_mock + ): + # given + get_orchestrator_config_mock.return_value = self.orchestrator_config + + message_orchestrator_mock = Mock() + message_orchestrator_mock.handle_message.return_value = self.messages + get_message_orchestrator_mock.return_value = message_orchestrator_mock + + os.environ["AZURE_OPENAI_MODEL"] = self.openai_model + + # when + app.test_client().post( + "/api/conversation/custom", + headers={"content-type": "application/json"}, + json=self.body, + ) + + # then + message_orchestrator_mock.handle_message.assert_called_once_with( + user_message=self.body["messages"][-1]["content"], + chat_history=self.body["messages"][:-1], + conversation_id=self.body["conversation_id"], + orchestrator=self.orchestrator_config, + ) + + @patch("code.app.app.get_orchestrator_config") + def test_converstation_custom_returns_error_resonse_on_exception( + self, get_orchestrator_config_mock + ): + # given + get_orchestrator_config_mock.side_effect = Exception("An error occurred") + + # when + response = app.test_client().post( + "/api/conversation/custom", + headers={"content-type": "application/json"}, + json=self.body, + ) + + # then + assert response.status_code == 500 + assert response.json == { + "error": "Exception in /api/conversation/custom. See log for more details." + } + + @patch("code.app.app.get_message_orchestrator") + @patch("code.app.app.get_orchestrator_config") + def test_converstation_custom_allows_multiple_messages_from_user( + self, get_orchestrator_config_mock, get_message_orchestrator_mock + ): + """This can happen if there was an error getting a response from the assistant for the previous user message.""" + + # given + get_orchestrator_config_mock.return_value = self.orchestrator_config + + message_orchestrator_mock = Mock() + message_orchestrator_mock.handle_message.return_value = self.messages + get_message_orchestrator_mock.return_value = message_orchestrator_mock + + os.environ["AZURE_OPENAI_MODEL"] = self.openai_model + + body = { + "conversation_id": "123", + "messages": [ + {"role": "user", "content": "Hello"}, + {"role": "assistant", "content": "Hi, how can I help?"}, + {"role": "user", "content": "What is the meaning of life?"}, + { + "role": "user", + "content": "Please, what is the meaning of life?", + }, + ], + } + + # when + response = app.test_client().post( + "/api/conversation/custom", + headers={"content-type": "application/json"}, + json=body, + ) + + # then + assert response.status_code == 200 + message_orchestrator_mock.handle_message.assert_called_once_with( + user_message=body["messages"][-1]["content"], + chat_history=body["messages"][:-1], + conversation_id=body["conversation_id"], + orchestrator=self.orchestrator_config, + )