diff --git a/.devcontainer/postCreate.sh b/.devcontainer/postCreate.sh index 6c333d58f..7aa1b20fa 100755 --- a/.devcontainer/postCreate.sh +++ b/.devcontainer/postCreate.sh @@ -6,6 +6,9 @@ pip install poetry # https://pypi.org/project/poetry-plugin-export/ pip install poetry-plugin-export + +poetry env use python3.11 + poetry config warnings.export false poetry install --with dev diff --git a/.env.sample b/.env.sample index b30b6255f..aaed17d15 100644 --- a/.env.sample +++ b/.env.sample @@ -57,3 +57,5 @@ AZURE_SPEECH_SERVICE_REGION= AZURE_AUTH_TYPE=keys USE_KEY_VAULT=true AZURE_KEY_VAULT_ENDPOINT= +# Chat conversation type to decide between custom or byod (bring your own data) conversation type +CONVERSATION_FLOW= \ No newline at end of file diff --git a/README.md b/README.md index f959fb18e..07912beba 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ By default, this repo comes with one specific set of RAG configurations includin The accelerator presented here provides several options, for example: * The ability to ground a model using both data and public web pages -* A backend that mimics the [On Your Data](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/use-your-data) flow, with the ability to switch to a custom backend +* A backend with support for 'custom' and 'On Your Data' [conversation flows](./docs/conversation_flow_options.md) * Advanced prompt engineering capabilities * An admin site for ingesting/inspecting/configuring your dataset on the fly * Push or Pull model for data ingestion: See [integrated vectorization](./docs/integrated_vectorization.md) documentation for more details diff --git a/code/backend/batch/utilities/helpers/config/conversation_flow.py b/code/backend/batch/utilities/helpers/config/conversation_flow.py new file mode 100644 index 000000000..29ccbf79f --- /dev/null +++ b/code/backend/batch/utilities/helpers/config/conversation_flow.py @@ -0,0 +1,6 @@ +from enum import Enum + + +class ConversationFlow(Enum): + CUSTOM = "custom" + BYOD = "byod" diff --git a/code/backend/batch/utilities/helpers/env_helper.py b/code/backend/batch/utilities/helpers/env_helper.py index 96819b0c6..595f5f7ad 100644 --- a/code/backend/batch/utilities/helpers/env_helper.py +++ b/code/backend/batch/utilities/helpers/env_helper.py @@ -4,6 +4,7 @@ from dotenv import load_dotenv from azure.identity import DefaultAzureCredential, get_bearer_token_provider from azure.keyvault.secrets import SecretClient +from backend.batch.utilities.helpers.config.conversation_flow import ConversationFlow logger = logging.getLogger(__name__) @@ -204,6 +205,10 @@ def __load_config(self, **kwargs) -> None: self.ORCHESTRATION_STRATEGY = os.getenv( "ORCHESTRATION_STRATEGY", "openai_function" ) + # Conversation Type - which chooses between custom or byod + self.CONVERSATION_FLOW = os.getenv( + "CONVERSATION_FLOW", ConversationFlow.CUSTOM.value + ) # Speech Service self.AZURE_SPEECH_SERVICE_NAME = os.getenv("AZURE_SPEECH_SERVICE_NAME", "") self.AZURE_SPEECH_SERVICE_REGION = os.getenv("AZURE_SPEECH_SERVICE_REGION") diff --git a/code/create_app.py b/code/create_app.py index c5811cfd3..22016b8a5 100644 --- a/code/create_app.py +++ b/code/create_app.py @@ -16,6 +16,7 @@ from backend.batch.utilities.helpers.env_helper import EnvHelper from backend.batch.utilities.helpers.orchestrator_helper import Orchestrator from backend.batch.utilities.helpers.config.config_helper import ConfigHelper +from backend.batch.utilities.helpers.config.conversation_flow import ConversationFlow from azure.mgmt.cognitiveservices import CognitiveServicesManagementClient from azure.identity import DefaultAzureCredential @@ -333,7 +334,6 @@ def static_file(path): def health(): return "OK" - @app.route("/api/conversation/azure_byod", methods=["POST"]) def conversation_azure_byod(): try: if env_helper.should_use_data(): @@ -342,19 +342,16 @@ def conversation_azure_byod(): return conversation_without_data(request, env_helper) except Exception as e: error_message = str(e) - logger.exception( - "Exception in /api/conversation/azure_byod | %s", error_message - ) + logger.exception("Exception in /api/conversation | %s", error_message) return ( jsonify( { - "error": "Exception in /api/conversation/azure_byod. See log for more details." + "error": "Exception in /api/conversation. See log for more details." } ), 500, ) - @app.route("/api/conversation/custom", methods=["POST"]) async def conversation_custom(): message_orchestrator = get_message_orchestrator() @@ -387,13 +384,28 @@ async def conversation_custom(): except Exception as e: error_message = str(e) - logger.exception( - "Exception in /api/conversation/custom | %s", error_message + logger.exception("Exception in /api/conversation | %s", error_message) + return ( + jsonify( + { + "error": "Exception in /api/conversation. See log for more details." + } + ), + 500, ) + + @app.route("/api/conversation", methods=["POST"]) + async def conversation(): + conversation_flow = env_helper.CONVERSATION_FLOW + if conversation_flow == ConversationFlow.CUSTOM.value: + return await conversation_custom() + elif conversation_flow == ConversationFlow.BYOD.value: + return conversation_azure_byod() + else: return ( jsonify( { - "error": "Exception in /api/conversation/custom. See log for more details." + "error": "Invalid conversation flow configured. Value can only be 'custom' or 'byod'." } ), 500, diff --git a/code/frontend/src/api/api.ts b/code/frontend/src/api/api.ts index e4400815f..14954d9de 100644 --- a/code/frontend/src/api/api.ts +++ b/code/frontend/src/api/api.ts @@ -1,23 +1,8 @@ import { ConversationRequest } from "./models"; -export async function conversationApi(options: ConversationRequest, abortSignal: AbortSignal): Promise { - const response = await fetch("/api/conversation/azure_byod", { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ - messages: options.messages - }), - signal: abortSignal - }); - - return response; -} - -export async function customConversationApi(options: ConversationRequest, abortSignal: AbortSignal): Promise { - const response = await fetch("/api/conversation/custom", { +export async function callConversationApi(options: ConversationRequest, abortSignal: AbortSignal): Promise { + const response = await fetch("/api/conversation", { method: "POST", headers: { "Content-Type": "application/json" diff --git a/code/frontend/src/pages/chat/Chat.tsx b/code/frontend/src/pages/chat/Chat.tsx index 1873b3e49..b0fb1b524 100644 --- a/code/frontend/src/pages/chat/Chat.tsx +++ b/code/frontend/src/pages/chat/Chat.tsx @@ -21,7 +21,7 @@ import { multiLingualSpeechRecognizer } from "../../util/SpeechToText"; import { ChatMessage, ConversationRequest, - customConversationApi, + callConversationApi, Citation, ToolMessageContent, ChatResponse, @@ -75,7 +75,7 @@ const Chat = () => { let result = {} as ChatResponse; try { - const response = await customConversationApi( + const response = await callConversationApi( request, abortController.signal ); diff --git a/code/tests/functional/app_config.py b/code/tests/functional/app_config.py index 18837a4da..0bad2ac93 100644 --- a/code/tests/functional/app_config.py +++ b/code/tests/functional/app_config.py @@ -1,6 +1,7 @@ import base64 import logging import os +from backend.batch.utilities.helpers.config.conversation_flow import ConversationFlow logger = logging.getLogger(__name__) @@ -69,6 +70,7 @@ class AppConfig: "LOAD_CONFIG_FROM_BLOB_STORAGE": "True", "LOGLEVEL": "DEBUG", "ORCHESTRATION_STRATEGY": "openai_function", + "CONVERSATION_FLOW": ConversationFlow.CUSTOM.value, "AZURE_SPEECH_RECOGNIZER_LANGUAGES": "en-US,es-ES", "TIKTOKEN_CACHE_DIR": f"{os.path.dirname(os.path.realpath(__file__))}/resources", "USE_ADVANCED_IMAGE_PROCESSING": "False", diff --git a/code/tests/functional/tests/backend_api/default/conftest.py b/code/tests/functional/tests/backend_api/default/conftest.py index 8a5b286c6..82824a9ee 100644 --- a/code/tests/functional/tests/backend_api/default/conftest.py +++ b/code/tests/functional/tests/backend_api/default/conftest.py @@ -1,5 +1,6 @@ import logging import pytest +from backend.batch.utilities.helpers.config.conversation_flow import ConversationFlow from tests.functional.app_config import AppConfig from tests.functional.tests.backend_api.common import get_free_port, start_app from backend.batch.utilities.helpers.config.config_helper import ConfigHelper @@ -34,6 +35,7 @@ def app_config(make_httpserver, ca): "USE_ADVANCED_IMAGE_PROCESSING": "True", "SSL_CERT_FILE": ca_temp_path, "CURL_CA_BUNDLE": ca_temp_path, + "CONVERSATION_FLOW": ConversationFlow.CUSTOM.value, } ) logger.info(f"Created app config: {app_config.get_all()}") diff --git a/code/tests/functional/tests/backend_api/default/test_advanced_image_processing.py b/code/tests/functional/tests/backend_api/default/test_advanced_image_processing.py index 64e359a9a..bca14e580 100644 --- a/code/tests/functional/tests/backend_api/default/test_advanced_image_processing.py +++ b/code/tests/functional/tests/backend_api/default/test_advanced_image_processing.py @@ -10,7 +10,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/custom" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ diff --git a/code/tests/functional/tests/backend_api/default/test_conversation_custom.py b/code/tests/functional/tests/backend_api/default/test_conversation.py similarity index 99% rename from code/tests/functional/tests/backend_api/default/test_conversation_custom.py rename to code/tests/functional/tests/backend_api/default/test_conversation.py index 6f94a9a0a..18313fa72 100644 --- a/code/tests/functional/tests/backend_api/default/test_conversation_custom.py +++ b/code/tests/functional/tests/backend_api/default/test_conversation.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/custom" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ @@ -646,7 +646,7 @@ def test_post_returns_error_when_downstream_fails( # when response = requests.post( - f"{app_url}/api/conversation/custom", + f"{app_url}/api/conversation", json={ "conversation_id": "123", "messages": [ @@ -661,5 +661,5 @@ def test_post_returns_error_when_downstream_fails( assert response.status_code == 500 assert response.headers["Content-Type"] == "application/json" assert json.loads(response.text) == { - "error": "Exception in /api/conversation/custom. See log for more details." + "error": "Exception in /api/conversation. See log for more details." } diff --git a/code/tests/functional/tests/backend_api/default/test_post_prompt_tool.py b/code/tests/functional/tests/backend_api/default/test_post_prompt_tool.py index 11cff3694..dbf06b81c 100644 --- a/code/tests/functional/tests/backend_api/default/test_post_prompt_tool.py +++ b/code/tests/functional/tests/backend_api/default/test_post_prompt_tool.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/custom" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ diff --git a/code/tests/functional/tests/backend_api/integrated_vectorization_custom_conversation/test_iv_question_answer_tool.py b/code/tests/functional/tests/backend_api/integrated_vectorization_custom_conversation/test_iv_question_answer_tool.py index 23d86e0a9..70fc8d81b 100644 --- a/code/tests/functional/tests/backend_api/integrated_vectorization_custom_conversation/test_iv_question_answer_tool.py +++ b/code/tests/functional/tests/backend_api/integrated_vectorization_custom_conversation/test_iv_question_answer_tool.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/custom" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ @@ -257,7 +257,7 @@ def test_post_returns_error_when_downstream_fails( # when response = requests.post( - f"{app_url}/api/conversation/custom", + f"{app_url}/api/conversation", json={ "conversation_id": "123", "messages": [ @@ -272,5 +272,5 @@ def test_post_returns_error_when_downstream_fails( assert response.status_code == 500 assert response.headers["Content-Type"] == "application/json" assert json.loads(response.text) == { - "error": "Exception in /api/conversation/custom. See log for more details." + "error": "Exception in /api/conversation. See log for more details." } diff --git a/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_search_documents_tool.py b/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_search_documents_tool.py index c49252869..9c300192d 100644 --- a/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_search_documents_tool.py +++ b/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_search_documents_tool.py @@ -10,7 +10,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/custom" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ diff --git a/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_text_processing_tool.py b/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_text_processing_tool.py index 52c1a12b7..953ae0005 100644 --- a/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_text_processing_tool.py +++ b/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_with_text_processing_tool.py @@ -10,7 +10,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/custom" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ diff --git a/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_without_tool_call.py b/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_without_tool_call.py index 6a7842e1a..375c4448b 100644 --- a/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_without_tool_call.py +++ b/code/tests/functional/tests/backend_api/sk_orchestrator/test_response_without_tool_call.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/custom" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ @@ -264,7 +264,7 @@ def test_post_returns_error_when_downstream_fails( # when response = requests.post( - f"{app_url}/api/conversation/custom", + f"{app_url}/api/conversation", json={ "conversation_id": "123", "messages": [ @@ -279,5 +279,5 @@ def test_post_returns_error_when_downstream_fails( assert response.status_code == 500 assert response.headers["Content-Type"] == "application/json" assert json.loads(response.text) == { - "error": "Exception in /api/conversation/custom. See log for more details." + "error": "Exception in /api/conversation. See log for more details." } diff --git a/code/tests/functional/tests/backend_api/with_byod/__init__.py b/code/tests/functional/tests/backend_api/with_byod/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/code/tests/functional/tests/backend_api/with_byod/conftest.py b/code/tests/functional/tests/backend_api/with_byod/conftest.py new file mode 100644 index 000000000..ed6a9a838 --- /dev/null +++ b/code/tests/functional/tests/backend_api/with_byod/conftest.py @@ -0,0 +1,60 @@ +import logging +import pytest +from backend.batch.utilities.helpers.config.conversation_flow import ConversationFlow +from tests.functional.app_config import AppConfig +from tests.functional.tests.backend_api.common import get_free_port, start_app +from backend.batch.utilities.helpers.config.config_helper import ConfigHelper +from backend.batch.utilities.helpers.env_helper import EnvHelper + +logger = logging.getLogger(__name__) + + +@pytest.fixture(scope="package") +def app_port() -> int: + logger.info("Getting free port") + return get_free_port() + + +@pytest.fixture(scope="package") +def app_url(app_port: int) -> str: + return f"http://localhost:{app_port}" + + +@pytest.fixture(scope="package") +def app_config(make_httpserver, ca): + logger.info("Creating APP CONFIG") + with ca.cert_pem.tempfile() as ca_temp_path: + app_config = AppConfig( + { + "AZURE_OPENAI_ENDPOINT": f"https://localhost:{make_httpserver.port}/", + "AZURE_SEARCH_SERVICE": f"https://localhost:{make_httpserver.port}/", + "AZURE_CONTENT_SAFETY_ENDPOINT": f"https://localhost:{make_httpserver.port}/", + "AZURE_SPEECH_REGION_ENDPOINT": f"https://localhost:{make_httpserver.port}/", + "AZURE_STORAGE_ACCOUNT_ENDPOINT": f"https://localhost:{make_httpserver.port}/", + "SSL_CERT_FILE": ca_temp_path, + "CURL_CA_BUNDLE": ca_temp_path, + "CONVERSATION_FLOW": ConversationFlow.BYOD.value, + } + ) + logger.info(f"Created app config: {app_config.get_all()}") + yield app_config + + +@pytest.fixture(scope="package", autouse=True) +def manage_app(app_port: int, app_config: AppConfig): + app_config.apply_to_environment() + EnvHelper.clear_instance() + ConfigHelper.clear_config() + start_app(app_port) + yield + app_config.remove_from_environment() + EnvHelper.clear_instance() + ConfigHelper.clear_config() + + +@pytest.fixture(autouse=True) +def reset_default_config(): + """ + Reset the default config after each test + """ + ConfigHelper.clear_config() diff --git a/code/tests/functional/tests/backend_api/default/test_azure_byod.py b/code/tests/functional/tests/backend_api/with_byod/test_conversation_flow.py similarity index 99% rename from code/tests/functional/tests/backend_api/default/test_azure_byod.py rename to code/tests/functional/tests/backend_api/with_byod/test_conversation_flow.py index 8d1e22226..17e196b9e 100644 --- a/code/tests/functional/tests/backend_api/default/test_azure_byod.py +++ b/code/tests/functional/tests/backend_api/with_byod/test_conversation_flow.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/azure_byod" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ diff --git a/code/tests/functional/tests/backend_api/without_data/conftest.py b/code/tests/functional/tests/backend_api/without_data/conftest.py index 74aa9c4f8..6b02638de 100644 --- a/code/tests/functional/tests/backend_api/without_data/conftest.py +++ b/code/tests/functional/tests/backend_api/without_data/conftest.py @@ -1,5 +1,6 @@ import logging import pytest +from backend.batch.utilities.helpers.config.conversation_flow import ConversationFlow from tests.functional.app_config import AppConfig from tests.functional.tests.backend_api.common import get_free_port, start_app from backend.batch.utilities.helpers.config.config_helper import ConfigHelper @@ -33,6 +34,7 @@ def app_config(make_httpserver, ca): "AZURE_STORAGE_ACCOUNT_ENDPOINT": f"https://localhost:{make_httpserver.port}/", "SSL_CERT_FILE": ca_temp_path, "CURL_CA_BUNDLE": ca_temp_path, + "CONVERSATION_FLOW": ConversationFlow.BYOD.value, } ) logger.info(f"Created app config: {app_config.get_all()}") diff --git a/code/tests/functional/tests/backend_api/without_data/test_azure_byod_without_data.py b/code/tests/functional/tests/backend_api/without_data/test_azure_byod_without_data.py index 24dd49033..f453e8ec0 100644 --- a/code/tests/functional/tests/backend_api/without_data/test_azure_byod_without_data.py +++ b/code/tests/functional/tests/backend_api/without_data/test_azure_byod_without_data.py @@ -12,7 +12,7 @@ pytestmark = pytest.mark.functional -path = "/api/conversation/azure_byod" +path = "/api/conversation" body = { "conversation_id": "123", "messages": [ diff --git a/code/tests/test_app.py b/code/tests/test_app.py index 858579258..3918825d9 100644 --- a/code/tests/test_app.py +++ b/code/tests/test_app.py @@ -2,10 +2,10 @@ This module tests the entry point for the application. """ -import os from unittest.mock import AsyncMock, MagicMock, patch, ANY import pytest from flask.testing import FlaskClient +from backend.batch.utilities.helpers.config.conversation_flow import ConversationFlow from create_app import create_app AZURE_SPEECH_KEY = "mock-speech-key" @@ -79,6 +79,7 @@ def env_helper_mock(): env_helper.SHOULD_STREAM = True env_helper.is_auth_type_keys.return_value = True env_helper.should_use_data.return_value = True + env_helper.CONVERSATION_FLOW = ConversationFlow.CUSTOM.value yield env_helper @@ -248,7 +249,7 @@ def test_conversation_custom_returns_correct_response( # when response = client.post( - "/api/conversation/custom", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -269,6 +270,7 @@ def test_conversation_custom_calls_message_orchestrator_correctly( self, get_orchestrator_config_mock, get_message_orchestrator_mock, + env_helper_mock, client, ): """Test that the custom conversation endpoint calls the message orchestrator correctly.""" @@ -279,11 +281,11 @@ def test_conversation_custom_calls_message_orchestrator_correctly( 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 + env_helper_mock.AZURE_OPENAI_MODEL = self.openai_model # when client.post( - "/api/conversation/custom", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -298,7 +300,7 @@ def test_conversation_custom_calls_message_orchestrator_correctly( @patch("create_app.get_orchestrator_config") def test_conversaation_custom_returns_error_response_on_exception( - self, get_orchestrator_config_mock, client + self, get_orchestrator_config_mock, env_helper_mock, client ): """Test that an error response is returned when an exception occurs.""" # given @@ -306,7 +308,7 @@ def test_conversaation_custom_returns_error_response_on_exception( # when response = client.post( - "/api/conversation/custom", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -314,13 +316,17 @@ def test_conversaation_custom_returns_error_response_on_exception( # then assert response.status_code == 500 assert response.json == { - "error": "Exception in /api/conversation/custom. See log for more details." + "error": "Exception in /api/conversation. See log for more details." } @patch("create_app.get_message_orchestrator") @patch("create_app.get_orchestrator_config") def test_conversation_custom_allows_multiple_messages_from_user( - self, get_orchestrator_config_mock, get_message_orchestrator_mock, client + self, + get_orchestrator_config_mock, + get_message_orchestrator_mock, + env_helper_mock, + client, ): """This can happen if there was an error getting a response from the assistant for the previous user message.""" @@ -346,7 +352,7 @@ def test_conversation_custom_allows_multiple_messages_from_user( # when response = client.post( - "/api/conversation/custom", + "/api/conversation", headers={"content-type": "application/json"}, json=body, ) @@ -360,6 +366,25 @@ def test_conversation_custom_allows_multiple_messages_from_user( orchestrator=self.orchestrator_config, ) + def test_conversation_returns_error_response_on_incorrect_conversation_flow_input( + self, env_helper_mock, client + ): + # given + env_helper_mock.CONVERSATION_FLOW = "bob" + + # when + response = client.post( + "/api/conversation", + headers={"content-type": "application/json"}, + json=self.body, + ) + + # then + assert response.status_code == 500 + assert response.json == { + "error": "Invalid conversation flow configured. Value can only be 'custom' or 'byod'." + } + class TestConversationAzureByod: def setup_method(self): @@ -461,7 +486,10 @@ def setup_method(self): @patch("create_app.AzureOpenAI") def test_conversation_azure_byod_returns_correct_response_when_streaming_with_data_keys( - self, azure_openai_mock: MagicMock, client: FlaskClient + self, + azure_openai_mock: MagicMock, + env_helper_mock: MagicMock, + client: FlaskClient, ): """Test that the Azure BYOD conversation endpoint returns the correct response.""" # given @@ -470,9 +498,11 @@ def test_conversation_azure_byod_returns_correct_response_when_streaming_with_da self.mock_streamed_response ) + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value + # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -547,6 +577,7 @@ def test_conversation_azure_byod_returns_correct_response_when_streaming_with_da """Test that the Azure BYOD conversation endpoint returns the correct response.""" # given env_helper_mock.is_auth_type_keys.return_value = False + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value openai_client_mock = azure_openai_mock.return_value openai_client_mock.chat.completions.create.return_value = ( self.mock_streamed_response @@ -554,7 +585,7 @@ def test_conversation_azure_byod_returns_correct_response_when_streaming_with_da # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -596,13 +627,14 @@ def test_conversation_azure_byod_returns_correct_response_when_not_streaming_wit """Test that the Azure BYOD conversation endpoint returns the correct response.""" # given env_helper_mock.SHOULD_STREAM = False + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value openai_client_mock = azure_openai_mock.return_value openai_client_mock.chat.completions.create.return_value = self.mock_response # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -634,15 +666,16 @@ def test_conversation_azure_byod_returns_correct_response_when_not_streaming_wit @patch("create_app.conversation_with_data") def test_conversation_azure_byod_returns_500_when_exception_occurs( - self, conversation_with_data_mock, client + self, conversation_with_data_mock, env_helper_mock, client ): """Test that an error response is returned when an exception occurs.""" # given conversation_with_data_mock.side_effect = Exception("Test exception") + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -650,7 +683,7 @@ def test_conversation_azure_byod_returns_500_when_exception_occurs( # then assert response.status_code == 500 assert response.json == { - "error": "Exception in /api/conversation/azure_byod. See log for more details." + "error": "Exception in /api/conversation. See log for more details." } @patch("create_app.AzureOpenAI") @@ -661,6 +694,7 @@ def test_conversation_azure_byod_returns_correct_response_when_not_streaming_wit # given env_helper_mock.should_use_data.return_value = False env_helper_mock.SHOULD_STREAM = False + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value openai_client_mock = MagicMock() azure_openai_mock.return_value = openai_client_mock @@ -676,7 +710,7 @@ def test_conversation_azure_byod_returns_correct_response_when_not_streaming_wit # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -727,6 +761,7 @@ def test_conversation_azure_byod_returns_correct_response_when_not_streaming_wit env_helper_mock.SHOULD_STREAM = False env_helper_mock.AZURE_AUTH_TYPE = "rbac" env_helper_mock.AZURE_OPENAI_STOP_SEQUENCE = "" + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value openai_client_mock = MagicMock() azure_openai_mock.return_value = openai_client_mock @@ -742,7 +777,7 @@ def test_conversation_azure_byod_returns_correct_response_when_not_streaming_wit # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -790,6 +825,7 @@ def test_conversation_azure_byod_returns_correct_response_when_streaming_without """Test that the Azure BYOD conversation endpoint returns the correct response.""" # given env_helper_mock.should_use_data.return_value = False + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value openai_client_mock = MagicMock() azure_openai_mock.return_value = openai_client_mock @@ -806,7 +842,7 @@ def test_conversation_azure_byod_returns_correct_response_when_streaming_without # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) @@ -822,7 +858,10 @@ def test_conversation_azure_byod_returns_correct_response_when_streaming_without @patch("create_app.AzureOpenAI") def test_conversation_azure_byod_uses_semantic_config( - self, azure_openai_mock: MagicMock, client: FlaskClient + self, + azure_openai_mock: MagicMock, + env_helper_mock: MagicMock, + client: FlaskClient, ): """Test that the Azure BYOD conversation endpoint uses the semantic configuration.""" # given @@ -830,10 +869,11 @@ def test_conversation_azure_byod_uses_semantic_config( openai_client_mock.chat.completions.create.return_value = ( self.mock_streamed_response ) + env_helper_mock.CONVERSATION_FLOW = ConversationFlow.BYOD.value # when response = client.post( - "/api/conversation/azure_byod", + "/api/conversation", headers={"content-type": "application/json"}, json=self.body, ) diff --git a/code/tests/utilities/orchestrator/test_semantic_kernel.py b/code/tests/utilities/orchestrator/test_semantic_kernel.py index aeb9fd572..e34be33b0 100644 --- a/code/tests/utilities/orchestrator/test_semantic_kernel.py +++ b/code/tests/utilities/orchestrator/test_semantic_kernel.py @@ -31,7 +31,9 @@ @pytest.fixture(autouse=True) def llm_helper_mock(): - with patch("backend.batch.utilities.orchestrator.semantic_kernel.LLMHelper") as mock: + with patch( + "backend.batch.utilities.orchestrator.semantic_kernel.LLMHelper" + ) as mock: llm_helper = mock.return_value llm_helper.get_sk_chat_completion_service.return_value = AzureChatCompletion( diff --git a/code/tests/utilities/test_azure_blob_storage_client.py b/code/tests/utilities/test_azure_blob_storage_client.py index 27adfac12..fd46b0654 100644 --- a/code/tests/utilities/test_azure_blob_storage_client.py +++ b/code/tests/utilities/test_azure_blob_storage_client.py @@ -170,7 +170,9 @@ def test_get_blob_sas(generate_blob_sas_mock: MagicMock): ) -@patch("backend.batch.utilities.helpers.azure_blob_storage_client.generate_container_sas") +@patch( + "backend.batch.utilities.helpers.azure_blob_storage_client.generate_container_sas" +) def test_get_container_sas(generate_container_sas_mock: MagicMock): # given client = AzureBlobStorageClient() diff --git a/docs/conversation_flow_options.md b/docs/conversation_flow_options.md new file mode 100644 index 000000000..c8bdd2b84 --- /dev/null +++ b/docs/conversation_flow_options.md @@ -0,0 +1,33 @@ +# Conversation Flow Options + +The backend service for 'Chat With Your Data' supports both 'custom' and 'On Your Data' conversation flows. + +## Configuration + +To switch between the two conversation flows, you can set the `CONVERSATION_FLOW` environment variable to either `custom` or `byod`. + +When running locally, you can set the environment variable in the `.env` file. + +## Options + +### Custom + +```env +CONVERSATION_FLOW=custom +``` + +Provides the option to use a custom orchestrator to handle the conversation flow. 'Chat With Your Data' provides support for the following orchestrators: + +- [Semantic Kernel](https://learn.microsoft.com/en-us/semantic-kernel/) +- [Langchain](https://python.langchain.com/v0.2/docs/introduction/) +- [OpenAI Function](https://learn.microsoft.com/en-us/azure/ai-services/openai/how-to/function-calling) + +### 'On Your Data' + +```env +CONVERSATION_FLOW=byod +``` + +With `CONVERSATION_FLOW` set to "byod", the backend service will mimic the [On Your Data](https://learn.microsoft.com/en-us/azure/ai-services/openai/concepts/use-your-data) flow. + +'On Your Data' enables you to run advanced AI models such as GPT-35-Turbo and GPT-4 on your own enterprise data without needing to train or fine-tune models. You can chat on top of and analyze your data with greater accuracy. You can specify sources to support the responses based on the latest information available in your designated data sources. diff --git a/infra/main.bicep b/infra/main.bicep index f57d93075..5608f5f09 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -127,6 +127,13 @@ param azureOpenAIVisionModelCapacity int = 10 ]) param orchestrationStrategy string = 'openai_function' +@description('Chat conversation type: custom or byod.') +@allowed([ + 'custom' + 'byod' +]) +param conversationFlow string = 'custom' + @description('Azure OpenAI Temperature') param azureOpenAITemperature string = '0' @@ -544,6 +551,7 @@ module web './app/web.bicep' = if (hostingModel == 'code') { AZURE_SPEECH_RECOGNIZER_LANGUAGES: recognizedLanguages USE_ADVANCED_IMAGE_PROCESSING: useAdvancedImageProcessing ORCHESTRATION_STRATEGY: orchestrationStrategy + CONVERSATION_FLOW: conversationFlow LOGLEVEL: logLevel } } @@ -615,6 +623,7 @@ module web_docker './app/web.bicep' = if (hostingModel == 'container') { AZURE_SPEECH_RECOGNIZER_LANGUAGES: recognizedLanguages USE_ADVANCED_IMAGE_PROCESSING: useAdvancedImageProcessing ORCHESTRATION_STRATEGY: orchestrationStrategy + CONVERSATION_FLOW: conversationFlow LOGLEVEL: logLevel } } @@ -1079,3 +1088,4 @@ output ADMIN_WEBSITE_NAME string = hostingModel == 'code' ? adminweb.outputs.WEBSITE_ADMIN_URI : adminweb_docker.outputs.WEBSITE_ADMIN_URI output LOGLEVEL string = logLevel +output CONVERSATION_FLOW string = conversationFlow diff --git a/infra/main.bicepparam b/infra/main.bicepparam index 01dc369ab..d69812bec 100644 --- a/infra/main.bicepparam +++ b/infra/main.bicepparam @@ -17,6 +17,7 @@ param azureSearchUseSemanticSearch = bool(readEnvironmentVariable('AZURE_SEARCH_ param orchestrationStrategy = readEnvironmentVariable('ORCHESTRATION_STRATEGY', 'openai_function') param logLevel = readEnvironmentVariable('LOGLEVEL', 'INFO') param recognizedLanguages = readEnvironmentVariable('AZURE_SPEECH_RECOGNIZER_LANGUAGES', 'en-US,fr-FR,de-DE,it-IT') +param conversationFlow = readEnvironmentVariable('CONVERSATION_FLOW', 'custom') // OpenAI parameters param azureOpenAIApiVersion = readEnvironmentVariable('AZURE_OPENAI_API_VERSION', '2024-02-01') diff --git a/infra/main.json b/infra/main.json index 3a9584621..693064cdb 100644 --- a/infra/main.json +++ b/infra/main.json @@ -5,7 +5,7 @@ "_generator": { "name": "bicep", "version": "0.27.1.19265", - "templateHash": "11889969251281398200" + "templateHash": "6490847733397864992" } }, "parameters": { @@ -262,6 +262,17 @@ "description": "Orchestration strategy: openai_function or semantic_kernel or langchain str. If you use a old version of turbo (0301), please select langchain" } }, + "conversationFlow": { + "type": "string", + "defaultValue": "custom", + "allowedValues": [ + "custom", + "byod" + ], + "metadata": { + "description": "Chat conversation type: custom or byod." + } + }, "azureOpenAITemperature": { "type": "string", "defaultValue": "0", @@ -2013,6 +2024,7 @@ "AZURE_SPEECH_RECOGNIZER_LANGUAGES": "[parameters('recognizedLanguages')]", "USE_ADVANCED_IMAGE_PROCESSING": "[parameters('useAdvancedImageProcessing')]", "ORCHESTRATION_STRATEGY": "[parameters('orchestrationStrategy')]", + "CONVERSATION_FLOW": "[parameters('conversationFlow')]", "LOGLEVEL": "[parameters('logLevel')]" } } @@ -2964,6 +2976,7 @@ "AZURE_SPEECH_RECOGNIZER_LANGUAGES": "[parameters('recognizedLanguages')]", "USE_ADVANCED_IMAGE_PROCESSING": "[parameters('useAdvancedImageProcessing')]", "ORCHESTRATION_STRATEGY": "[parameters('orchestrationStrategy')]", + "CONVERSATION_FLOW": "[parameters('conversationFlow')]", "LOGLEVEL": "[parameters('logLevel')]" } } @@ -11070,6 +11083,10 @@ "LOGLEVEL": { "type": "string", "value": "[parameters('logLevel')]" + }, + "CONVERSATION_FLOW": { + "type": "string", + "value": "[parameters('conversationFlow')]" } } } \ No newline at end of file