diff --git a/SqlDatabaseVectorSearch/Program.cs b/SqlDatabaseVectorSearch/Program.cs index e485ddd..6748b7c 100644 --- a/SqlDatabaseVectorSearch/Program.cs +++ b/SqlDatabaseVectorSearch/Program.cs @@ -1,13 +1,12 @@ +using System.ComponentModel; using Microsoft.AspNetCore.Http.HttpResults; using Microsoft.Data.SqlClient; -using Microsoft.OpenApi.Models; using Microsoft.SemanticKernel; -using MinimalHelpers.OpenApi; using SqlDatabaseVectorSearch.Models; using SqlDatabaseVectorSearch.Services; using SqlDatabaseVectorSearch.Settings; using TinyHelpers.AspNetCore.Extensions; -using TinyHelpers.AspNetCore.Swagger; +using TinyHelpers.AspNetCore.OpenApi; var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("appsettings.local.json", optional: true, reloadOnChange: true); @@ -24,7 +23,13 @@ builder.Services.AddScoped(_ => return sqlConnection; }); -builder.Services.AddMemoryCache(); +builder.Services.AddHybridCache(options => +{ + options.DefaultEntryOptions = new() + { + LocalCacheExpiration = appSettings.MessageExpiration + }; +}); // Semantic Kernel is used to generate embeddings and to reformulate questions taking into account all the previous interactions, // so that embeddings themselves can be generated more accurately. @@ -35,11 +40,8 @@ builder.Services.AddKernel() builder.Services.AddSingleton(); builder.Services.AddScoped(); -builder.Services.AddEndpointsApiExplorer(); -builder.Services.AddSwaggerGen(options => +builder.Services.AddOpenApi(options => { - options.SwaggerDoc("v1", new OpenApiInfo { Title = "SQL Database Vector Search API", Version = "v1" }); - options.AddDefaultResponse(); }); @@ -56,11 +58,11 @@ app.UseStatusCodePages(); if (app.Environment.IsDevelopment()) { - app.UseSwagger(); + app.MapOpenApi(); app.UseSwaggerUI(options => { options.RoutePrefix = string.Empty; - options.SwaggerEndpoint("/swagger/v1/swagger.json", "SQL Database Vector Search API v1"); + options.SwaggerEndpoint("/openapi/v1.json", builder.Environment.ApplicationName); }); } @@ -71,24 +73,15 @@ documentsApiGroup.MapGet(string.Empty, async (VectorSearchService vectorSearchSe var documents = await vectorSearchService.GetDocumentsAsync(); return TypedResults.Ok(documents); }) -.WithOpenApi(operation => -{ - operation.Summary = "Gets the list of documents"; - return operation; -}); +.WithSummary("Gets the list of documents"); documentsApiGroup.MapGet("{documentId:guid}/chunks", async (Guid documentId, VectorSearchService vectorSearchService) => { var documents = await vectorSearchService.GetDocumentChunksAsync(documentId); return TypedResults.Ok(documents); }) -.WithOpenApi(operation => -{ - operation.Summary = "Gets the list of chunks of a given document"; - operation.Description = "The list does not contain embedding. Use '/api/documents/{documentId}/chunks/{documentChunkId}' to get the embedding for a given chunk."; - - return operation; -}); +.WithSummary("Gets the list of chunks of a given document") +.WithDescription("The list does not contain embedding. Use '/api/documents/{documentId}/chunks/{documentChunkId}' to get the embedding for a given chunk."); documentsApiGroup.MapGet("{documentId:guid}/chunks/{documentChunkId:guid}", async Task, NotFound>> (Guid documentId, Guid documentChunkId, VectorSearchService vectorSearchService) => { @@ -100,13 +93,11 @@ documentsApiGroup.MapGet("{documentId:guid}/chunks/{documentChunkId:guid}", asyn return TypedResults.Ok(chunk); }) -.WithOpenApi(operation => -{ - operation.Summary = "Gets the details of a given chunk, includings its embedding"; - return operation; -}); +.ProducesProblem(StatusCodes.Status404NotFound) +.WithSummary("Gets the details of a given chunk, includings its embedding"); -documentsApiGroup.MapPost(string.Empty, async (IFormFile file, VectorSearchService vectorSearchService, Guid? documentId = null) => +documentsApiGroup.MapPost(string.Empty, async (IFormFile file, VectorSearchService vectorSearchService, + [Description("The unique identifier of the document. If not provided, a new one will be generated. If you specify an existing documentId, the corresponding document will be overwritten.")] Guid? documentId = null) => { using var stream = file.OpenReadStream(); documentId = await vectorSearchService.ImportAsync(stream, file.FileName, documentId); @@ -114,43 +105,26 @@ documentsApiGroup.MapPost(string.Empty, async (IFormFile file, VectorSearchServi return TypedResults.Ok(new UploadDocumentResponse(documentId.Value)); }) .DisableAntiforgery() -.WithOpenApi(operation => -{ - operation.Summary = "Uploads a document"; - operation.Description = "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."; - - operation.Parameter("documentId").Description = "The unique identifier of the document. If not provided, a new one will be generated. If you specify an existing documentId, the corresponding document will be overwritten."; - - return operation; -}); +.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."); documentsApiGroup.MapDelete("{documentId:guid}", async (Guid documentId, VectorSearchService vectorSearchService) => { await vectorSearchService.DeleteDocumentAsync(documentId); return TypedResults.NoContent(); }) -.WithOpenApi(operation => -{ - operation.Summary = "Deletes a document"; - operation.Description = "This endpoint deletes the document and all its chunks."; +.WithSummary("Deletes a document") +.WithDescription("This endpoint deletes the document and all its chunks."); - return operation; -}); - -app.MapPost("/api/ask", async (Question question, VectorSearchService vectorSearchService, bool reformulate = true) => +app.MapPost("/api/ask", async (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) => { var response = await vectorSearchService.AskQuestionAsync(question, reformulate); return TypedResults.Ok(response); }) -.WithOpenApi(operation => -{ - operation.Summary = "Asks a question"; - operation.Description = "The question will be reformulated taking into account the context of the chat identified by the given ConversationId."; - - operation.Parameter("reformulate").Description = "If true, the question will be reformulated taking into account the context of the chat identified by the given ConversationId."; - - return operation; -}) +.WithSummary("Asks a question") +.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/ChatService.cs b/SqlDatabaseVectorSearch/Services/ChatService.cs index e6f3727..bcce685 100644 --- a/SqlDatabaseVectorSearch/Services/ChatService.cs +++ b/SqlDatabaseVectorSearch/Services/ChatService.cs @@ -1,18 +1,14 @@ using System.Text; -using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Caching.Hybrid; using Microsoft.SemanticKernel.ChatCompletion; -using SqlDatabaseVectorSearch.Settings; namespace SqlDatabaseVectorSearch.Services; -public class ChatService(IMemoryCache cache, IChatCompletionService chatCompletionService, IOptions appSettingsOptions) +public class ChatService(IChatCompletionService chatCompletionService, HybridCache cache) { - private readonly AppSettings appSettings = appSettingsOptions.Value; - public async Task CreateQuestionAsync(Guid conversationId, string question) { - var chat = new ChatHistory(cache.Get(conversationId) ?? []); + var chat = await GetChatHistoryAsync(conversationId); var embeddingQuestion = $""" Reformulate the following question taking into account the context of the chat to perform embeddings search: @@ -67,23 +63,33 @@ public class ChatService(IMemoryCache cache, IChatCompletionService chatCompleti var answer = await chatCompletionService.GetChatMessageContentAsync(chat)!; // Add question and answer to the chat history. - var history = new ChatHistory(cache.Get(conversationId) ?? []); - history.AddUserMessage(question); - history.AddAssistantMessage(answer.Content!); - - await UpdateCacheAsync(conversationId, history); + await SetChatHistoryAsync(conversationId, question, answer.Content!); return answer.Content!; } - private Task UpdateCacheAsync(Guid conversationId, ChatHistory chat) - { - if (chat.Count > appSettings.MessageLimit) - { - chat = new ChatHistory(chat.TakeLast(appSettings.MessageLimit)); - } + private async Task UpdateCacheAsync(Guid conversationId, ChatHistory chat) + => await cache.SetAsync(conversationId.ToString(), chat); - cache.Set(conversationId, chat, appSettings.MessageExpiration); - return Task.CompletedTask; + private async Task GetChatHistoryAsync(Guid conversationId) + { + var historyCache = await cache.GetOrCreateAsync(conversationId.ToString(), + (cancellationToken) => + { + return ValueTask.FromResult([]); + }); + + var chat = new ChatHistory(historyCache); + return chat; + } + + private async Task SetChatHistoryAsync(Guid conversationId, string question, string answer) + { + var history = await GetChatHistoryAsync(conversationId); + + history.AddUserMessage(question); + history.AddAssistantMessage(answer); + + await UpdateCacheAsync(conversationId, history); } } diff --git a/SqlDatabaseVectorSearch/Settings/AppSettings.cs b/SqlDatabaseVectorSearch/Settings/AppSettings.cs index 4466e1a..dd04b00 100644 --- a/SqlDatabaseVectorSearch/Settings/AppSettings.cs +++ b/SqlDatabaseVectorSearch/Settings/AppSettings.cs @@ -10,7 +10,5 @@ public class AppSettings public int MaxRelevantChunks { get; init; } = 5; - public int MessageLimit { get; init; } - public TimeSpan MessageExpiration { get; init; } } diff --git a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj index 4c071b8..130d019 100644 --- a/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj +++ b/SqlDatabaseVectorSearch/SqlDatabaseVectorSearch.csproj @@ -4,19 +4,19 @@ net9.0 enable enable - $(NoWarn);SKEXP0001;SKEXP0010;SKEXP0050; + $(NoWarn);SKEXP0001;SKEXP0010;SKEXP0050;EXTEXP0018 - - + + + - + - diff --git a/SqlDatabaseVectorSearch/appsettings.json b/SqlDatabaseVectorSearch/appsettings.json index 79648fe..53b2465 100644 --- a/SqlDatabaseVectorSearch/appsettings.json +++ b/SqlDatabaseVectorSearch/appsettings.json @@ -22,7 +22,6 @@ "MaxTokensPerParagraph": 1024, "OverlapTokens": 100, "MaxRelevantChunks": 10, - "MessageLimit": 20, "MessageExpiration": "00:05:00" }, "Logging": {