From 1ef2d384ec63e057e54bf740764b5f3285b9ba64 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 28 Jan 2025 11:00:45 +0100 Subject: [PATCH] Add streaming support and improve JSON serialization - Updated `Response` record class to allow nullable `Question` and `Answer` properties; moved `StreamState` enum to a new file. - Added `JsonStringEnumConverter` in `Program.cs` for better enum serialization. - Corrected terminology in document upload endpoint description. - Introduced `/api/ask-streaming` endpoint for streaming question responses. - Added `AskStreamingAsync` method in `VectorSearchService` for handling streaming logic. - Created `StreamState.cs` to define `StreamState` enum with `Start`, `Append`, and `End` values. --- SqlDatabaseVectorSearch/Models/Response.cs | 10 ++----- SqlDatabaseVectorSearch/Models/StreamState.cs | 8 +++++ SqlDatabaseVectorSearch/Program.cs | 29 ++++++++++++++++++- .../Services/VectorSearchService.cs | 19 ++++++++++++ 4 files changed, 57 insertions(+), 9 deletions(-) create mode 100644 SqlDatabaseVectorSearch/Models/StreamState.cs diff --git a/SqlDatabaseVectorSearch/Models/Response.cs b/SqlDatabaseVectorSearch/Models/Response.cs index b2e5c5e..faae25d 100644 --- a/SqlDatabaseVectorSearch/Models/Response.cs +++ b/SqlDatabaseVectorSearch/Models/Response.cs @@ -1,10 +1,4 @@ namespace SqlDatabaseVectorSearch.Models; -public record class Response(string Question, string Answer, StreamState? StreamState = null); - -public enum StreamState -{ - Start, - Append, - End -} \ No newline at end of file +// Question and Asnwer can be null when using response streaming. +public record class Response(string? Question, string? Answer, StreamState? StreamState = null); \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Models/StreamState.cs b/SqlDatabaseVectorSearch/Models/StreamState.cs new file mode 100644 index 0000000..2bb25ad --- /dev/null +++ b/SqlDatabaseVectorSearch/Models/StreamState.cs @@ -0,0 +1,8 @@ +namespace SqlDatabaseVectorSearch.Models; + +public enum StreamState +{ + Start, + Append, + End +} \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs index 522a857..8e0f222 100644 --- a/SqlDatabaseVectorSearch/Program.cs +++ b/SqlDatabaseVectorSearch/Program.cs @@ -1,4 +1,5 @@ using System.ComponentModel; +using System.Text.Json.Serialization; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.EntityFrameworkCore; using Microsoft.SemanticKernel; @@ -49,6 +50,11 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); +builder.Services.ConfigureHttpJsonOptions(options => +{ + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); +}); + builder.Services.AddOpenApi(options => { options.RemoveServerList(); @@ -114,7 +120,7 @@ documentsApiGroup.MapPost(string.Empty, async (IFormFile file, VectorSearchServi .DisableAntiforgery() .ProducesProblem(StatusCodes.Status400BadRequest) .WithSummary("Uploads a document") -.WithDescription("Uploads a document to SQL Database and saves its embedding using the new native Vector type. The document will be indexed and used to answer questions. Currently, only PDF files are supported."); +.WithDescription("Uploads a document to SQL Database and saves its embedding using the native VECTOR type. The document will be indexed and used to answer questions. Currently, only PDF files are supported."); documentsApiGroup.MapDelete("{documentId:guid}", async (Guid documentId, VectorSearchService vectorSearchService) => { @@ -134,4 +140,25 @@ app.MapPost("/api/ask", async (Question question, VectorSearchService vectorSear .WithDescription("The question will be reformulated taking into account the context of the chat identified by the given ConversationId.") .WithTags("Ask"); +app.MapPost("/api/ask-streaming", (Question question, VectorSearchService vectorSearchService, + [Description("If true, the question will be reformulated taking into account the context of the chat identified by the given ConversationId.")] bool reformulate = true) => +{ + async IAsyncEnumerable Stream() + { + // Requests a streaming response. + var responseStream = vectorSearchService.AskStreamingAsync(question, reformulate); + + await foreach (var delta in responseStream) + { + yield return delta; + await Task.Delay(50); + } + } + + return Stream(); +}) +.WithSummary("Asks a question and gets the response as streaming") +.WithDescription("The question will be reformulated taking into account the context of the chat identified by the given ConversationId.") +.WithTags("Ask"); + app.Run(); \ No newline at end of file diff --git a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs index 53ce117..62493bc 100644 --- a/SqlDatabaseVectorSearch/Services/VectorSearchService.cs +++ b/SqlDatabaseVectorSearch/Services/VectorSearchService.cs @@ -88,6 +88,25 @@ public class VectorSearchService(ApplicationDbContext dbContext, ITextEmbeddingG return new Response(reformulatedQuestion, answer); } + public async IAsyncEnumerable AskStreamingAsync(Question question, bool reformulate = true) + { + var (reformulatedQuestion, chunks) = await CreateContextAsync(question, reformulate); + + var answerStream = chatService.AskStreamingAsync(question.ConversationId, chunks, reformulatedQuestion); + + // The first message contains the original question. + yield return new Response(reformulatedQuestion, null, StreamState.Start); + + // Return each token as a partial response. + await foreach (var token in answerStream) + { + yield return new Response(null, token, StreamState.Append); + } + + // The last message tells the client that the stream has ended. + yield return new Response(null, null, StreamState.End); + } + private async Task<(string Question, IEnumerable Chunks)> CreateContextAsync(Question question, bool reformulate = true) { // Reformulate the following question taking into account the context of the chat to perform keyword search and embeddings: