diff --git a/README.md b/README.md index 557cd53d..caaf1d6d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ products: - azure-blob-storage - azure-container-apps - azure-cognitive-search +- azure-redis-cache - azure-openai - aspnet-core - blazor @@ -56,11 +57,11 @@ description: A csharp sample app that chats with your data using OpenAI and AI S [![Open in GitHub - Codespaces](https://img.shields.io/static/v1?style=for-the-badge&label=GitHub+Codespaces&message=Open&color=brightgreen&logo=github)](https://github.com/codespaces/new?hide_repo_select=true&ref=main&repo=624102171&machine=standardLinux32gb&devcontainer_path=.devcontainer%2Fdevcontainer.json&location=WestUs2) [![Open in Remote - Containers](https://img.shields.io/static/v1?style=for-the-badge&label=Remote%20-%20Containers&message=Open&color=blue&logo=visualstudiocode)](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/azure-samples/azure-search-openai-demo-csharp) -This sample demonstrates a few approaches for creating ChatGPT-like experiences over your own data using the Retrieval Augmented Generation pattern. It uses Azure OpenAI Service to access the ChatGPT model (`gpt-35-turbo`), and Azure Cognitive Search for data indexing and retrieval. +This sample demonstrates a few approaches for creating ChatGPT-like experiences over your own data using the Retrieval Augmented Generation pattern. It uses Azure OpenAI Service to access the ChatGPT model (`gpt-35-turbo`), and Azure Cache for Redis for Vector Similariy Search. The repo includes sample data so it's ready to try end-to-end. In this sample application, we use a fictitious company called Contoso Electronics, and the experience allows its employees to ask questions about the benefits, internal policies, as well as job descriptions and roles. -![RAG Architecture](docs/appcomponents-version-4.png) +![RAG Architecture](docs/appcomponents-version-5.png) For more details on how this application was built, check out: @@ -84,7 +85,7 @@ We want to hear from you! Are you interested in building or currently building i - **User interface** - The application’s chat interface is a [Blazor WebAssembly](https://learn.microsoft.com/aspnet/core/blazor/) application. This interface is what accepts user queries, routes request to the application backend, and displays generated responses. - **Backend** - The application backend is an [ASP.NET Core Minimal API](https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis/overview). The backend hosts the Blazor static web application and what orchestrates the interactions among the different services. Services used in this application include: - - [**Azure Cognitive Search**](https://learn.microsoft.com/azure/search/search-what-is-azure-search) – indexes documents from the data stored in an Azure Storage Account. This makes the documents searchable using [vector search](https://learn.microsoft.com/azure/search/search-get-started-vector) capabilities. + - [**Azure Cache for Redis**](https://learn.microsoft.com/azure/azure-cache-for-redis/) – indexes documents from the data stored in an Azure Storage Account. This makes the documents searchable using [vector search](https://redis.io/docs/interact/search-and-query/advanced-concepts/vectors/) capabilities. - [**Azure OpenAI Service**](https://learn.microsoft.com/azure/ai-services/openai/overview) – provides the Large Language Models to generate responses. [Semantic Kernel](https://learn.microsoft.com/semantic-kernel/whatissk) is used in conjunction with the Azure OpenAI Service to orchestrate the more complex AI workflows. ## Getting Started @@ -94,7 +95,7 @@ We want to hear from you! Are you interested in building or currently building i In order to deploy and run this example, you'll need - **Azure Account** - If you're new to Azure, get an [Azure account for free](https://aka.ms/free) and you'll get some free Azure credits to get started. -- **Azure subscription with access enabled for the Azure OpenAI service** - [You can request access](https://aka.ms/oaiapply). You can also visit [the Cognitive Search docs](https://azure.microsoft.com/free/cognitive-search/) to get some free Azure credits to get you started. +- **Azure subscription with access enabled for the Azure OpenAI service** - [You can request access](https://aka.ms/oaiapply). - **Azure account permissions** - Your Azure Account must have `Microsoft.Authorization/roleAssignments/write` permissions, such as [User Access Administrator](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#user-access-administrator) or [Owner](https://learn.microsoft.com/azure/role-based-access-control/built-in-roles#owner). @@ -108,7 +109,7 @@ Pricing varies per region and usage, so it isn't possible to predict exact costs - [**Azure Container Apps**](https://azure.microsoft.com/pricing/details/container-apps/) - [**Azure OpenAI Service**](https://azure.microsoft.com/pricing/details/cognitive-services/openai-service/) - [**Azure Form Recognizer**](https://azure.microsoft.com/pricing/details/form-recognizer/) -- [**Azure Cognitive Search**](https://azure.microsoft.com/pricing/details/search/) +- [**Azure Cache for Redis**](https://azure.microsoft.com/pricing/details/cache/) - [**Azure Blob Storage**](https://azure.microsoft.com/pricing/details/storage/blobs/) - [**Azure Monitor**](https://azure.microsoft.com/pricing/details/monitor/) @@ -362,7 +363,7 @@ to production. Here are some things to consider: ## Resources - [Revolutionize your Enterprise Data with ChatGPT: Next-gen Apps w/ Azure OpenAI and Cognitive Search](https://aka.ms/entgptsearchblog) -- [Azure Cognitive Search](https://learn.microsoft.com/azure/search/search-what-is-azure-search) +- [Azure Cache for Redis](https://learn.microsoft.com/azure/azure-cache-for-redis/cache-overview) - [Azure OpenAI Service](https://learn.microsoft.com/azure/cognitive-services/openai/overview) - [`Azure.AI.OpenAI` NuGet package](https://www.nuget.org/packages/Azure.AI.OpenAI) - [Original Blazor App](https://github.com/IEvangelist/blazor-azure-openai) diff --git a/app/SharedWebComponents/Components/Answer.razor b/app/SharedWebComponents/Components/Answer.razor index 106f4e65..8ca0959f 100644 --- a/app/SharedWebComponents/Components/Answer.razor +++ b/app/SharedWebComponents/Components/Answer.razor @@ -46,20 +46,23 @@ + + Disabled="@(Retort is { Thoughts: null })">
-                                @(RemoveLeadingAndTrailingLineBreaks(Retort.Context.ThoughtsString))
+                            @(RemoveLeadingAndTrailingLineBreaks(Retort.Thoughts!))
                         
+ + ToolTip="Show the supporting content." Disabled="@(Retort is { DataPoints: null } or { DataPoints.Length: 0 })"> - + + diff --git a/app/SharedWebComponents/Components/Answer.razor.cs b/app/SharedWebComponents/Components/Answer.razor.cs index b7c580e8..f9da73ab 100644 --- a/app/SharedWebComponents/Components/Answer.razor.cs +++ b/app/SharedWebComponents/Components/Answer.razor.cs @@ -4,7 +4,9 @@ namespace SharedWebComponents.Components; public sealed partial class Answer { - [Parameter, EditorRequired] public required ResponseChoice Retort { get; set; } + + [Parameter, EditorRequired] public required ApproachResponse Retort { get; set; } + [Parameter, EditorRequired] public required EventCallback FollowupQuestionClicked { get; set; } [Inject] public required IPdfViewer PdfViewer { get; set; } @@ -14,7 +16,9 @@ public sealed partial class Answer protected override void OnParametersSet() { _parsedAnswer = ParseAnswerToHtml( - Retort.Message.Content, Retort.CitationBaseUrl); + + Retort.Answer, Retort.CitationBaseUrl); + base.OnParametersSet(); } diff --git a/app/SharedWebComponents/Components/AnswerError.razor b/app/SharedWebComponents/Components/AnswerError.razor index 4a9e5be7..eb609669 100644 --- a/app/SharedWebComponents/Components/AnswerError.razor +++ b/app/SharedWebComponents/Components/AnswerError.razor @@ -2,10 +2,12 @@ - @Error.Error + + @Error.Answer - Unable to retrieve valid response from the server. + @Error.Error +
OnRetryClicked { get; set; } private async Task OnRetryClickedAsync() diff --git a/app/SharedWebComponents/Models/AnswerResult.cs b/app/SharedWebComponents/Models/AnswerResult.cs index 3bc92540..2f1b2029 100644 --- a/app/SharedWebComponents/Models/AnswerResult.cs +++ b/app/SharedWebComponents/Models/AnswerResult.cs @@ -4,6 +4,8 @@ namespace SharedWebComponents.Models; public readonly record struct AnswerResult( bool IsSuccessful, - ChatAppResponseOrError? Response, + + ApproachResponse? Response, + Approach Approach, TRequest Request) where TRequest : ApproachRequest; diff --git a/app/SharedWebComponents/Pages/Chat.razor b/app/SharedWebComponents/Pages/Chat.razor index a36b4467..602be8ec 100644 --- a/app/SharedWebComponents/Pages/Chat.razor +++ b/app/SharedWebComponents/Pages/Chat.razor @@ -52,7 +52,9 @@ - + + + }
diff --git a/app/SharedWebComponents/Pages/Chat.razor.cs b/app/SharedWebComponents/Pages/Chat.razor.cs index 8211087a..01e50625 100644 --- a/app/SharedWebComponents/Pages/Chat.razor.cs +++ b/app/SharedWebComponents/Pages/Chat.razor.cs @@ -9,7 +9,9 @@ public sealed partial class Chat private string _lastReferenceQuestion = ""; private bool _isReceivingResponse = false; - private readonly Dictionary _questionAndAnswerMap = []; + + private readonly Dictionary _questionAndAnswerMap = []; + [Inject] public required ISessionStorageService SessionStorage { get; set; } @@ -42,13 +44,15 @@ private async Task OnAskClickedAsync() try { var history = _questionAndAnswerMap - .Where(x => x.Value?.Choices is { Length: > 0}) - .SelectMany(x => new ChatMessage[] { new ChatMessage("user", x.Key.Question), new ChatMessage("assistant", x.Value!.Choices[0].Message.Content) }) + + .Where(x => x.Value is not null) + .Select(x => new ChatTurn(x.Key.Question, x.Value!.Answer)) .ToList(); - history.Add(new ChatMessage("user", _userQuestion)); + history.Add(new ChatTurn(_userQuestion)); + + var request = new ChatRequest([.. history], Settings.Approach, Settings.Overrides); - var request = new ChatRequest([.. history], Settings.Overrides); var result = await ApiClient.ChatConversationAsync(request); _questionAndAnswerMap[_currentQuestion] = result.Response; diff --git a/app/SharedWebComponents/Services/ApiClient.cs b/app/SharedWebComponents/Services/ApiClient.cs index 9b15f7fd..c744b327 100644 --- a/app/SharedWebComponents/Services/ApiClient.cs +++ b/app/SharedWebComponents/Services/ApiClient.cs @@ -112,19 +112,25 @@ private async Task> PostRequestAsync( if (response.IsSuccessStatusCode) { - var answer = await response.Content.ReadFromJsonAsync(); + + var answer = await response.Content.ReadFromJsonAsync(); return result with { IsSuccessful = answer is not null, - Response = answer, + Response = answer + }; } else { - var errorTitle = $"HTTP {(int)response.StatusCode} : {response.ReasonPhrase ?? "☹️ Unknown error..."}"; - var answer = new ChatAppResponseOrError( - Array.Empty(), - errorTitle); + + var answer = new ApproachResponse( + $"HTTP {(int)response.StatusCode} : {response.ReasonPhrase ?? "☹️ Unknown error..."}", + null, + [], + null, + "Unable to retrieve valid response from the server."); + return result with { diff --git a/app/functions/EmbedFunctions/Program.cs b/app/functions/EmbedFunctions/Program.cs index b5cce41b..950abd7c 100644 --- a/app/functions/EmbedFunctions/Program.cs +++ b/app/functions/EmbedFunctions/Program.cs @@ -91,10 +91,12 @@ uri is not null AzureComputerVisionService? visionClient = null; bool includeImageEmbeddingsField = false; + if (useVision) { var visionEndpoint = Environment.GetEnvironmentVariable("AZURE_COMPUTER_VISION_ENDPOINT") ?? throw new ArgumentNullException("AZURE_COMPUTER_VISION_ENDPOINT is null"); var httpClient = new HttpClient(); + visionClient = new AzureComputerVisionService(httpClient, visionEndpoint, new DefaultAzureCredential()); includeImageEmbeddingsField = true; } @@ -124,8 +126,10 @@ uri is not null searchIndexClient: searchIndexClient, documentAnalysisClient: documentClient, corpusContainerClient: corpusContainer, + computerVisionService: visionClient, includeImageEmbeddingsField: includeImageEmbeddingsField, + logger: logger); } }); diff --git a/app/functions/EmbedFunctions/Services/EmbeddingAggregateService.cs b/app/functions/EmbedFunctions/Services/EmbeddingAggregateService.cs index de450a87..69d71aac 100644 --- a/app/functions/EmbedFunctions/Services/EmbeddingAggregateService.cs +++ b/app/functions/EmbedFunctions/Services/EmbeddingAggregateService.cs @@ -58,7 +58,9 @@ await corpusClient.SetMetadataAsync(new Dictionary } catch (Exception ex) { - logger.LogError(ex, "Failed to embed: {Name}, error: {Message} stackTrace: {StackTrace}", blobName, ex.Message, ex.StackTrace); + + logger.LogError(ex, "Failed to embed: {Name}, error: {Message}", blobName, ex.Message); + throw; } } diff --git a/docs/appcomponents-version-5.png b/docs/appcomponents-version-5.png new file mode 100644 index 00000000..b12504d8 Binary files /dev/null and b/docs/appcomponents-version-5.png differ diff --git a/infra/main.bicep b/infra/main.bicep index d7ba6248..1f562e0e 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -11,7 +11,9 @@ param location string param tags string = '' @description('Location for the OpenAI resource group') -@allowed([ 'canadaeast', 'westus', 'eastus', 'eastus2', 'francecentral', 'swedencentral', 'switzerlandnorth', 'uksouth', 'japaneast', 'northcentralus', 'australiaeast' ]) + +@allowed([ 'canadaeast', 'westus', 'eastus', 'eastus2', 'francecentral', 'switzerlandnorth', 'uksouth', 'japaneast', 'northcentralus', 'australiaeast' ]) + @metadata({ azd: { type: 'location' @@ -185,6 +187,7 @@ param useVision bool = false @description('Use Azure Cache. default: false') param useRedis bool = false + var abbrs = loadJsonContent('./abbreviations.json') var resourceToken = toLower(uniqueString(subscription().id, environmentName, location))